Platforms

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.js

Credentials and the base URL resolve exactly as they do for the CLI. Environment variables win, then whatever acme login and acme config saved:

SettingEnvironment variableSaved by
Bearer tokenACME_TOKENacme login
API keyACME_API_KEY, or one variable per headeracme login
Basic authACME_USERNAME / ACME_PASSWORDacme login
Base URLACME_BASE_URLacme config set base-url
Environmentacme 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 instead

Local 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 through read_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 whose security names OAuth scopes ends with Requires scope accounts:write.; an operation that works without credentials in an API that otherwise has them says No credential needed. Argument descriptions gain enum meanings from x-enumDescriptions (or x-enum-descriptions / x-enum-varnames) as Values: active (open and usable), frozen (temporarily locked), the schema default, Markdown; use literal newlines. for text/markdown content, Deprecated. for deprecated parameters, and, for a <thing>_id argument whose thing has exactly one list operation, IDs come from things_list. The same notes appear in read_docs and CLI help.
  • When the spec cannot say it (a three-step upload, a slow endpoint), write the tool description yourself: mcp.tool_descriptions in 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: readOnlyHint for GET and HEAD, for GraphQL queries, and for POST operations the spec names as reads (search, query, find, count); destructiveHint for DELETE, PUT and PATCH (they remove or overwrite) and for operations named like cancel, archive, revoke, while POST creates are additive; idempotentHint for reads, PUT and DELETE; openWorldHint false, 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. allOf compositions are merged into one flat argument list, readOnly properties 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 outputSchema when your spec documents a success body (paginated tools describe items, hasMore, nextPage), and successful results carry the JSON as structuredContent alongside the text block. Error results keep their JSON in the text only, since structuredContent must match the schema.
  • Operations whose body is a plain object take its fields as top-level arguments. Other body shapes take a single body argument. 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 --fields does, and the way to keep large responses small. An operation that already has a fields parameter keeps its own.
  • GraphQL operations that return an object accept a select argument, 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_docs searches 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 through page.
  • read_docs returns an operation's full reference by tool name or dotted resource.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_docs and read_docs, as above.
  • execute, which runs any operation by name with a JSON arguments object. 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 (accountId for account_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, US to the enum member us) 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: -P7D and -7d (seven days ago), +PT1H and +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-DD for date, an ISO instant for date-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 --http launched 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 --out materializes 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 resource block; 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:

  1. Reference. Every operation's summary, description, and argument table, generated from your spec and shipped in the package. Works offline.
  2. Guides. Your documentation site, read through llms.txt and llms-full.txt. Most docs hosts publish these automatically. The URL resolves in this order: acme config set docs-url, then docs_url in the project's config, then your spec's externalDocs.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 deploy

Callers' 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 Authorization header 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 429 with Retry-After. tools/list and server/discover are 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_docs and read_docs tools ship, backed by your spec and your docs site.
  • The project's mcp.tool_mode and mcp.instructions config apply, so large APIs can serve the three-tool meta shape and agents read your guidance on connect.
  • /mcp/<slug>/readonly is 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 fields and 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> --cursor

See Connect MCP clients for the manual configuration.

Hosted or self-hosted

Hosted endpointYour package's server
Runs ontypeshipYour users' machines, or your Cloudflare account
SpecAlways the project's current specFrozen at generation
CredentialsPassed through per requestEnvironment, or saved by acme login
Rate limit120/min per caller, 1,200/min per endpointNone built in
UsageCounted, shown in project settingsNot collected
SetupPaste a URLBuild 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-28 only. Every request carries params._meta["io.modelcontextprotocol/protocolVersion"] and ["io.modelcontextprotocol/clientCapabilities"]; a request without them is -32602, a request for another version is -32022 with the supported list in error.data. Every result carries resultType: "complete" and the server's identity in _meta. server/discover answers with the supported versions, the tools capability, and short instructions.
  • tools/list results carry ttlMs (one hour: the tool set is fixed at generation) and cacheScope: "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/cancelled for 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 revisions 2025-11-25 and earlier) gets an error that names 2026-07-28, as the spec asks of modern-only servers. Claude Code and Claude Desktop speak 2026-07-28 today; 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 with initialize and 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.

On this page