Skip to content

Serve the API as an MCP server over streamable HTTP - #6

Open
kamath wants to merge 1 commit into
mainfrom
mcp-server-streamable-http
Open

Serve the API as an MCP server over streamable HTTP#6
kamath wants to merge 1 commit into
mainfrom
mcp-server-streamable-http

Conversation

@kamath

@kamath kamath commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

POST /api/mcp exposes every operation in the generated OpenAPI document as an MCP tool, verbatim — the tool list is a pure function of the document, so a new route becomes a tool with no work in the bridge, with describeRoute's prose as the description and hono-openapi's derived operationId as the name.

The only transformation is shape: MCP passes a single flat arguments object, so plan flattens each operation's path, query and body inputs into one schema and rebuild puts them back, throwing at startup if two differently-scoped names would collide.

It is stateless (fresh server per POST, enableJsonResponse, GET → 405) and dispatches in-process via app.request — a separate process importing @repo/api would be a second PGlite opener — with results capped at 64 KB because getApiTypes over the whole catalog is 8.6 MB, and a failing route surfaced as isError rather than a thrown JSON-RPC error so a caller can correct itself.

Also adds @modelcontextprotocol/sdk as ssr.external plus a direct frontend dependency (PGlite's treatment, for the same reason), src/mcp.test.ts whose central assertion is that every tool's advertised inputs equal its operation's declared inputs, and a CLAUDE.md section.

🤖 Generated with Claude Code

`POST /api/mcp` exposes every operation in the generated OpenAPI document as an
MCP tool, verbatim. The tool list is a pure function of the document, so a route
that exists is a tool that exists and adding an endpoint needs no work in the
bridge; `describeRoute`'s prose becomes the tool description and hono-openapi's
derived `operationId` becomes the tool name.

The one transformation is shape: MCP hands a tool a single flat `arguments`
object while an HTTP operation splits its inputs across path, query and body, so
`plan` flattens the three and `rebuild` puts them back. A name arriving in two
places throws at startup rather than silently routing a value to the wrong slot.

Notes on the design:

- It lives on `app` beside `/api/openapi` and `/api/scalar`, not on `routes` — a
  protocol endpoint belongs in neither the document nor the RPC client's type.
  Carrying no `describeRoute` is also what stops it generating a tool for itself.
- Dispatch is in-process via `app.request`, so the bridge never talks to its own
  HTTP port. A separate MCP process importing `@repo/api` would be a second
  PGlite opener, which corrupts the data dir silently.
- Stateless: a fresh server and transport per POST, nothing to garbage-collect.
  A module-level transport registry is the hazard that already forced `db.ts`
  onto `globalThis`, since Vite re-evaluates on every edit. `enableJsonResponse`
  keeps replies plain JSON; GET returns 405.
- Results are capped at 64 KB, because `getApiTypes` over the whole catalog is
  8.6 MB and a response that evicts the conversation it was fetched for is worse
  than an error — it looks like success.
- A failing route is `isError` in the result, never a thrown JSON-RPC error, so
  a caller can read the failure and correct itself. Only an unadvertised tool
  name is a protocol error.

`@modelcontextprotocol/sdk` is `ssr.external` in the frontend and a direct
dependency there, the same treatment PGlite gets and for the same reason: it is
Node-targeted and pulls in express and ajv.

Co-Authored-By: Claude <noreply@anthropic.com>
@kamath
kamath force-pushed the mcp-server-streamable-http branch from a5309d2 to 39e14c3 Compare July 26, 2026 05:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant