MCP server
Every operation in your API becomes a tool that coding agents can call. Zero dependencies, two transports, one login shared with your CLI, and a hosted endpoint at a stable URL.
The mcp platform adds a Model Context Protocol server to your TypeScript package. It exposes each operation as a typed tool, ships a search_docs and read_docs pair so agents can read your reference and guides, and shares credentials with your CLI. It has no runtime dependencies. The bin is named after your API, so the Acme API ships acme-mcp.
The server speaks the current MCP revision, 2026-07-28: stateless, one request at a time, no initialize handshake and no sessions. That is the shape the protocol settled on, and it is the shape a generated API server wants anyway. Claude Code connects to it out of the box; see Protocol notes for what other clients need.
The server reaches agents two ways: inside your package, as a process the client spawns or a Worker you deploy, or as the hosted endpoint typeship runs for you at a stable URL. To connect Claude Code, Cursor, or Claude Desktop, see Connect MCP clients.
Transports
The same compiled server speaks two transports. The transport decides where credentials come from.
stdio
node dist/mcp.js speaks newline-delimited JSON-RPC 2.0 over stdin and stdout. This is the transport local MCP clients spawn themselves. Build the package once first:
cd acme
npm install
npm run build # clients run dist/mcp.jsCredentials and the base URL resolve exactly as they do for the CLI. Environment variables win, then whatever acme login and acme config saved:
| Setting | Environment variable | Saved by |
|---|---|---|
| Bearer token | ACME_TOKEN | acme login |
| API key | ACME_API_KEY, or one variable per header | acme login |
| Basic auth | ACME_USERNAME / ACME_PASSWORD | acme login |
| Base URL | ACME_BASE_URL | acme config set base-url |
| Environment | acme config set environment |
One login covers the CLI and every agent client. No secrets need to live in client config files. If no base URL can be resolved, the first tool call returns an isError result naming the fix (set ACME_BASE_URL or acme config set base-url); the server stays up.
The package also declares the server as a bin, so npx acme-mcp (or acme-mcp after a global install) starts it the same way node dist/mcp.js does.
Nothing but JSON-RPC is written to stdout. Startup errors and ACME_DEBUG=1 request lines go to stderr.
Streamable HTTP
node dist/mcp.js --http 3000 serves the same tools over HTTP. The port falls back to PORT, then 3000. The module also exports handleHttp, a fetch-style handler (Request in, Response out) you can mount on any route or edge runtime.
Requests without an Origin header (every non-browser MCP client) are always accepted. When Origin is present the server validates it, as the spec requires against DNS rebinding: same-host and localhost origins are allowed, anything else is 403 unless listed in ACME_MCP_ALLOWED_ORIGINS (comma-separated; * allows any). CORS headers reflect the allowed origin.
In HTTP mode, each request's Authorization header is forwarded to your API, so every caller uses their own credentials. Environment variables still work for credentials the server itself should hold.
The HTTP server is stateless, as the 2026-07-28 transport is by design. It accepts one JSON-RPC message per POST, never mints or reads session ids, and does not open a server-to-client stream. Notifications return 202. Anything other than POST returns 405.
Every request must carry the transport's mirrored headers, and the server checks them against the body as the spec requires: MCP-Protocol-Version (must equal the version in _meta), Mcp-Method (must equal method), and Mcp-Name on tools/call (must equal params.name; the =?base64?…?= encoding is decoded first). A missing or mismatched header is 400 with JSON-RPC error -32020. An unknown method is 404 with -32601. A JSON-RPC batch or a malformed message is 400 with -32600.
The acme mcp command
The fastest way to wire the server into an agent client is the CLI's own mcp command:
acme mcp # print the server entry as JSON
acme mcp --claude # write ./.mcp.json (Claude Code)
acme mcp --cursor # write ./.cursor/mcp.json
acme mcp --claude-desktop # write the Claude Desktop config
acme mcp --url https://typeship.dev/mcp/<slug> --claude # a remote endpoint insteadLocal entries are { "command": "node", "args": ["/abs/path/acme/dist/mcp.js"] }. Remote entries are { "type": "http", "url": "..." }. Existing servers in the target file are preserved. Only the acme entry is added or replaced. Claude Desktop's config file only launches local servers, so --claude-desktop with --url stops with the instruction to add the URL as a connector in the app instead.
The written entry has no env block. The server reads the credentials that acme login saved, so tokens never sit in a config file checked into a repo.
Tools
One tool per operation, named resource_method in lower snake case: accounts_list, accounts_get, spec_versions_get_content. GraphQL root fields follow the same shape: query_account, mutation_create_account. Names are limited to lowercase letters, digits, and underscores, capped at 60 characters, and deduplicated.
- Descriptions give agents the operation summary, the first sentence of its description when that adds something, and the method and path as ground truth:
Create an account. Accounts hold a balance in one currency. POST /accounts. Paginated tools add(paginated: returns one page plus hasMore and nextPage arguments). The full description stays available throughread_docs. - Guardrails the spec already states are folded in, so the agent reads them before the call instead of in the error. A deprecated operation's description leads with
Deprecated.; an operation whosesecuritynames OAuth scopes ends withRequires scope accounts:write.; an operation that works without credentials in an API that otherwise has them saysNo credential needed.Argument descriptions gain enum meanings fromx-enumDescriptions(orx-enum-descriptions/x-enum-varnames) asValues: active (open and usable), frozen (temporarily locked), the schemadefault,Markdown; use literal newlines.fortext/markdowncontent,Deprecated.for deprecated parameters, and, for a<thing>_idargument whose thing has exactly one list operation,IDs come from things_list.The same notes appear inread_docsand CLI help. - When the spec cannot say it (a three-step upload, a slow endpoint), write the tool description yourself:
mcp.tool_descriptionsin the project's config, keyed by operationId or"METHOD /path", replaces the derived text for that operation in the package and on the hosted endpoint. Keys that match nothing are generation warnings. - Each tool carries a
title(the operation summary) and spec annotations:readOnlyHintfor GET and HEAD, for GraphQL queries, and for POST operations the spec names as reads (search,query,find,count);destructiveHintfor DELETE, PUT and PATCH (they remove or overwrite) and for operations named likecancel,archive,revoke, while POST creates are additive;idempotentHintfor reads, PUT and DELETE;openWorldHintfalse, since the server talks to one known API. Clients such as Claude Code use these to auto-approve reads and confirm destructive writes. - Input schemas are JSON Schema 2020-12 derived from your spec: real types, enums, formats, descriptions, defaults, and required fields for path, query, header, and body parameters.
allOfcompositions are merged into one flat argument list,readOnlyproperties are left out of inputs, and nullable fields keep their nullability. Nested schemas are inlined to a fixed depth so models can read them without resolving references. - Tools carry an
outputSchemawhen your spec documents a success body (paginated tools describeitems,hasMore,nextPage), and successful results carry the JSON asstructuredContentalongside the text block. Error results keep their JSON in the text only, sincestructuredContentmust match the schema. - Operations whose body is a plain object take its fields as top-level arguments. Other body shapes take a single
bodyargument. Argument names are your wire names. - Every tool also takes
fields, an array of dotted paths that keeps only those keys of the result (["id", "name", "owner.email"]), applied per item on paginated tools. It is the same projection the CLI's--fieldsdoes, and the way to keep large responses small. An operation that already has afieldsparameter keeps its own. - GraphQL operations that return an object accept a
selectargument, a raw selection set such as{ id name }, that replaces the default depth-2 selection. It is declared in the tool's input schema with the default spelled out.
Every server also ships two documentation tools:
search_docssearches your reference (operations, parameters) and, when a docs site is configured, your guides. Reference matches are ranked (tool name first, then summary, path and argument names, then description) and paged 15 at a time throughpage.read_docsreturns an operation's full reference by tool name or dottedresource.method, or a guide page from your docs site.
The reference comes from the spec and is baked into the package. Guides come from your documentation site through the llms.txt convention. See Docs for agents.
Tool mode for large APIs
Hundreds of per-operation tools flood an agent's context. The meta tool mode collapses the server to exactly three tools:
search_docsandread_docs, as above.execute, which runs any operation by name with a JSONargumentsobject. It accepts tool names (accounts_create) or dotted names (accounts.create).
Set it with mcp.tool_mode in the project's config: auto, operations, or meta. auto is the default and switches to meta above 100 operations. The hosted endpoint honors the same setting.
Arguments are checked, never dropped
Before anything reaches your API, the arguments of a tool call are checked against the tool's input schema, and every problem comes back at once as one isError result, so a single round trip fixes the call:
- An unknown argument name is an error with a suggestion (
"nam": did you mean "name"?). When only case or punctuation differs (accountIdforaccount_id), the value is matched to the accepted name and the call proceeds. - Values are coerced where the intent is clear (
"true"to a boolean,"3"to an integer, a JSON string to an object, a lone scalar to a one-item array,USto the enum memberus) and rejected where it is not ("abc"for an integer), naming what was expected. - Missing required arguments are listed with their descriptions.
- Date and date-time arguments take relative forms as well as ISO 8601:
-P7Dand-7d(seven days ago),+PT1Hand+2h(ahead),7 days ago,in 2 weeks,today,yesterday,now. They resolve against the server's clock before the request, so your API sees an absolute value (YYYY-MM-DDfordate, an ISO instant fordate-time). An unsigned duration (7d) is rejected as ambiguous, naming the signed forms. The argument's description says which forms it takes.
{
"error": "InvalidArguments",
"code": "INVALID_ARGUMENTS",
"message": "2 problems with the arguments to accounts_create; nothing was sent to the API.",
"issues": [
{ "code": "UNKNOWN_ARGUMENT", "argument": "nam", "message": "Unknown argument \"nam\"; did you mean \"name\"? Accepted: name, currency, tags, regions, metadata, fields." },
{ "code": "MISSING_ARGUMENT", "argument": "name", "message": "Missing required argument \"name\"." }
],
"next_steps": ["Fix the arguments listed in issues and call accounts_create again.", "read_docs {\"page\": \"accounts_create\"} lists every argument with its type and whether it is required."]
}Nothing is dropped silently: an argument your API would not understand never becomes a request that fails for an unclear reason, or worse, a request that succeeds without the model's intent.
Results and errors
Tool results are compact JSON text content. Paginated tools return one page plus a continuation signal and, when there is another page, the exact arguments that fetch it, whatever the API's pagination style:
{ "items": [ ... ], "hasMore": true, "nextPage": { "cursor": "cur_2", "limit": 20 } }Results are capped at 64,000 characters (about 16k tokens, under every major client's own limit) so the agent sees an explanation instead of a mid-JSON cut. Over the cap, a page is cut to whole items and gains a truncated note saying how many were omitted and how to continue: offset and last-id pagination styles resume exactly at the cut through nextPage; cursor and page styles say to call again with a smaller page size. A plain object loses its largest keys, each replaced by a marker that names the fields call that fetches it alone. The package's server takes the cap from <PREFIX>_MCP_MAX_RESULT_CHARS.
API failures come back as tool results with isError: true, carrying the typed error, a stable code, your API's own error payload, and what to do next, so the agent can branch without parsing prose:
{
"error": "NotFoundError",
"code": "NOT_FOUND",
"message": "...",
"status": 404,
"body": { ... },
"docs_url": "https://docs.acme.example",
"next_steps": ["Check the id; list the resource first to find the right one."]
}The codes are the generated CLI's: NO_AUTH, AUTH_INVALID, PLAN_LIMIT, NOT_FOUND, INVALID_REQUEST, RATE_LIMITED, SERVER_ERROR, NETWORK_ERROR, VALIDATION_FAILED, plus INVALID_ARGUMENTS for the checks above. A 401 names where credentials come from on the transport in use (the environment variable and login on stdio, the Authorization header over HTTP); a 429 carries the wait. See Output and exit codes for what each code means. JSON-RPC protocol errors are reserved for unknown tools, unknown methods, and malformed requests.
Files: binary responses and uploads
A binary response (an image, a PDF, an archive: anything the API returns that is not JSON or text) never comes back as {}. Images up to 4 MB come back as an image content block the model can look at, with a text summary (media_type, bytes) beside it. Other binaries depend on where the server runs:
- The package's server, run as a process on the agent's machine (stdio, or
--httplaunched there), saves the bytes to a per-server directory under the OS temp dir and returns{ saved_to, media_type, bytes }, the same way the CLI's--outmaterializes files. - A remote server (the hosted endpoint, or the Cloudflare worker) has no disk of the agent's. Binaries up to 1 MB are embedded as a base64
resourceblock; larger ones return a description with the size and the advice to fetch through the SDK, the CLI, or the package's local server.
Uploads go the other way. Operations with a multipart/form-data body or a raw binary body are tools on the package's local server, where each file argument is a local path the server reads and sends ("image": "/Users/me/avatar.png"); an unreadable path is an argument error before anything is sent. On a remote server they are not tools, since the server cannot see the agent's files; the argument description and the server instructions say so. Generation reports which operations are affected.
Read-only and narrower servers
Agents that should look but not touch get a server that cannot write. --read-only (or <PREFIX>_MCP_READ_ONLY=1) keeps only read operations: GET and HEAD, GraphQL queries, and POST operations the spec names as reads. Writes are out of the callable set, not merely unlisted: a write called by name is an unknown tool, and execute does not know it either. The server's instructions say it is read-only and how many operations that hides.
acme mcp install --claude --read-only # registers node dist/mcp.js --read-only--tools accounts,transfers.create (or <PREFIX>_MCP_TOOLS) narrows the server to named resources, tool names, or dotted resource.method, for agents that need a corner of a large API without the meta tool shape. Both switches compose with mcp.tool_mode. The hosted endpoint serves its read-only twin at /mcp/<slug>/readonly.
Instructions for agents
server/discover returns instructions that agents read once when they connect: what the tools are, how arguments and results behave, where credentials come from on this transport, whether the server is read-only, and, when the API has an operation that returns the caller (the CLI's whoami target, or cli.whoami_operation in config), which tool to call first to learn whose credential this is. Add your own with mcp.instructions in the project's config (up to 2,000 characters): what to call first, conventions the spec does not state, what not to do. The package's server and the hosted endpoint both carry it. serverInfo names your docs site as websiteUrl when one is configured.
Generation reports the size of tools/list as agents pay for it (characters and an approximate token count, in the generation's meta), and warns above 80 KB, since every client loads the list once per session.
OAuth discovery
When your spec declares an OAuth2 flow, the HTTP transport serves RFC 9728 protected-resource metadata at /.well-known/oauth-protected-resource, naming the authorization server derived from the flow's authorization and token URLs. A request that arrives without an Authorization header, on a server holding no environment credentials, receives a 401 whose challenge points at that metadata:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.acme.example/.well-known/oauth-protected-resource"That challenge is how MCP clients discover where to obtain a token. The OAuth flow itself runs against your authorization server. The MCP server never mints, stores, or validates tokens. It forwards the caller's bearer token to your API, which validates it like any other request.
Docs for agents
Two layers back search_docs and read_docs, and the CLI's docs command:
- Reference. Every operation's summary, description, and argument table, generated from your spec and shipped in the package. Works offline.
- Guides. Your documentation site, read through
llms.txtandllms-full.txt. Most docs hosts publish these automatically. The URL resolves in this order:acme config set docs-url, thendocs_urlin the project's config, then your spec'sexternalDocs.url.
Nothing is fetched unless a docs tool or command runs. Fetches time out after 10 seconds.
Host it yourself
Every package generated with the mcp platform includes a Cloudflare Worker entry point (src/worker.ts) and a preconfigured wrangler.toml. Running a remote MCP server on your own account is one command:
npx wrangler deployCallers' Authorization headers pass through to your API, so the Worker holds no secrets by default. Use npx wrangler secret put ACME_TOKEN when the server itself should authenticate. A self-hosted deployment serves the spec it was generated from. The hosted endpoint is the always-current alternative that never needs redeploying.
Hosted endpoint
EnterpriseAvailable on Enterprisetypeship runs this service for you, priced by contract.
The server in your package is something your users install and run. The hosted endpoint is the same server, run by typeship, at a URL your users paste into their agent client:
https://typeship.dev/mcp/<slug>Turn on hosted MCP endpoint under the MCP server platform in project settings. It needs the MCP server platform on and Enterprise. The URL appears next to the switch, and on the API as mcp_url. Turning the MCP server platform off turns the endpoint off with it.
- Tools are built from the project's current spec with spec patches applied. Changes are picked up within five minutes. Nothing to regenerate, nothing to redeploy.
- Tool calls run through the same request runtime every generated SDK ships: the same query and body encoding, retries with
Retry-After, per-operation retry policies, idempotency keys, and page walking. A tool behaves the same on the hosted URL and in the package. - Each caller's
Authorizationheader passes through to your API. typeship holds no credentials for your API. - Tool calls are rate limited per caller (by bearer credential, else by IP) at 120 per minute and per endpoint at 1,200 per minute; over the limit is
429withRetry-After.tools/listandserver/discoverare not limited. - Every tool call is counted per day and per tool. Project settings shows the last 30 days next to the endpoint URL: calls, errors, rate-limited calls, and average upstream latency.
- The same
search_docsandread_docstools ship, backed by your spec and your docs site. - The project's
mcp.tool_modeandmcp.instructionsconfig apply, so large APIs can serve the three-toolmetashape and agents read your guidance on connect. /mcp/<slug>/readonlyis the same endpoint with every write left out of the callable set, for agents that should only read; it serves its own protected-resource metadata at/mcp/<slug>/readonly/oauth-protected-resource.- Arguments are checked and coerced, results are projected with
fieldsand capped in size, and errors carry codes and next steps, exactly as in the package. - When your spec declares OAuth2, unauthenticated requests get the standard discovery challenge, with protected-resource metadata at
/mcp/<slug>/oauth-protected-resource.
Any client that speaks Streamable HTTP can add the URL. Your CLI can write the entry for the common ones:
acme mcp --url https://typeship.dev/mcp/<slug> --claude
acme mcp --url https://typeship.dev/mcp/<slug> --cursorSee Connect MCP clients for the manual configuration.
Hosted or self-hosted
| Hosted endpoint | Your package's server | |
|---|---|---|
| Runs on | typeship | Your users' machines, or your Cloudflare account |
| Spec | Always the project's current spec | Frozen at generation |
| Credentials | Passed through per request | Environment, or saved by acme login |
| Rate limit | 120/min per caller, 1,200/min per endpoint | None built in |
| Usage | Counted, shown in project settings | Not collected |
| Setup | Paste a URL | Build the package, or npx wrangler deploy |
Both share tool names, input schemas, argument checking, result shaping, the error contract, and the docs tools. Point external users at the hosted URL, and keep the package's server for local development and for users who need the server inside their own network. Upstream requests from the hosted endpoint time out after 30 seconds.
Protocol notes
- Protocol revision
2026-07-28only. Every request carriesparams._meta["io.modelcontextprotocol/protocolVersion"]and["io.modelcontextprotocol/clientCapabilities"]; a request without them is-32602, a request for another version is-32022with the supported list inerror.data. Every result carriesresultType: "complete"and the server's identity in_meta.server/discoveranswers with the supported versions, thetoolscapability, and short instructions. tools/listresults carryttlMs(one hour: the tool set is fixed at generation) andcacheScope: "public", so clients and shared caches may hold them. Tools come back in spec order, so the list is stable across calls.- Cancellation: on stdio,
notifications/cancelledfor an in-flight request suppresses its response; on HTTP, closing the connection is the signal. Other notifications are accepted and ignored. - No legacy handshake. A client that opens with
initialize(protocol revisions2025-11-25and earlier) gets an error that names2026-07-28, as the spec asks of modern-only servers. Claude Code and Claude Desktop speak2026-07-28today; Cursor (3.2) does not yet. Clients built on the official SDKs speak it when they enable version negotiation (TypeScript:versionNegotiation: { mode: "auto" }or a pin); with the SDK's default settings they still open withinitializeand will not connect. Ask your API's consumers to update their client if that happens; the error message tells them why. - The server exits when its stdin closes.
- On stdio, one JSON-RPC message per line on stdin and stdout.
- Operations that stream server-sent events are not exposed as tools (a tool result is one value, not a stream); they are still SDK methods and CLI commands, and generation reports which ones. Operations with binary or multipart request bodies are tools on the package's local server only; see Files.
CLI
Every operation in your API becomes a command with typed flags, JSON output, and exit codes, plus login, config, docs, completion, and webhook tooling including the hosted relay.
Overview
A project is one spec and everything typeship generates from it: languages, platforms, package names, destinations, and the history of every generation.