Skip to content

Mount a stateless MCP server at /api/mcp with the document read tools #226

Description

@HMarzban

Problem

Nothing exposes docs.plus to an MCP host. Claude, Claude Desktop, Claude mobile and Cursor all accept a remote MCP server URL, and docs.plus serves none.

One deployment fact decides the shape, and it is invisible on a laptop. docker-compose.prod.yml runs the rest-api service with replicas: 2 and no sticky session, while the hocuspocus service carries five sticky-cookie labels. So the repository already sorts stateful services from stateless ones, and rest-api sits deliberately in the stateless group.

Mount a stateful MCP session there, and initialize lands on one container while the next request lands on the other. The session is not found. Traefik cannot rescue it, because its stickiness is cookie-based while MCP keys a session on the Mcp-Session-Id header.

What to do

Mount a stateless remote streamable-HTTP MCP server inside the existing Hono app:

app.route('/api/mcp', mcpModule.router) in apps/hocuspocus.server/src/index.ts, following the house module shape used by document-content and document-versions.

It must mount inside the existing app. A separate service that accepts a caller's token and forwards it to the REST API is token passthrough, and that is forbidden.

Statelessness is one option: omit sessionIdGenerator. The SDK then skips session validation, so a request landing on a container that never saw initialize is accepted. The SDK also throws if a stateless transport is reused, so build the transport per request. That matches the house rule that modules carry no top-level side effects.

Ship read tools only in this issue: find_documents, get_outline, read_document. Every tool takes a slug, never a documentId, and resolves it under the caller's own token.

Serve the protected-resource metadata under /api, not /.well-known, which Traefik does not publish. An unauthenticated call returns 401 carrying WWW-Authenticate: Bearer resource_metadata="...".

Acceptance

  • initialize on one process, then tools/list on a second process, succeeds. Two processes are two memories, which is the production condition.
  • Two requests to the same process do not throw Stateless transport cannot be reused across requests.
  • An unauthenticated call returns 401 with a WWW-Authenticate header pointing at the metadata.
  • No tool answers without a login.
  • A bearer token minted for another resource is refused. lib/auth.ts:64-112 reads no aud today, and RFC 8707 audience binding is a specification requirement.
  • A present and non-allowlisted Origin is refused with 403 at the mount, before any tool dispatch. Hono CORS only writes response headers and cannot refuse.
  • Every tool carries a title. The read tools carry readOnlyHint: true; every write tool carries destructiveHint: true. Hosts use these to decide auto-permission, so they are a control.
  • Every read tool truncates its result and says so, because hosts cap a result near 150,000 characters while the server accepts 5 MiB.
  • /api/mcp answers through the production edge with no new Traefik rule.

Build rules from the security review

These are cheap to honour while writing the module, and expensive to retrofit.

  • Import only the web-standard transport. @modelcontextprotocol/sdk/server/webStandardStreamableHttp.js pulls SDK-internal modules only. server/streamableHttp.js pulls a Node HTTP adapter, and anything under server/auth/ pulls a second HTTP framework and a second rate limiter. Supabase is the authorization server, so the SDK's own auth router has no job here.
  • Pin the SDK to an exact version, the way metascraper and mammoth are already pinned. The SDK owns the authorization boundary: the 401 shape, the WWW-Authenticate header and the audience check. Take each bump as its own change, proved by the cold install this issue already requires.
  • find_documents scopes to the caller. Send the owner filter as the token subject for the caller's own view, and run the public view as a separate query. Strip the owner UUID from every result row; the host does not need it.
  • Give /api/mcp its own budget. Charge it on the client address and the verified token subject, and refuse when either is exhausted. Never on the subject alone: packages/supabase/config.toml:165 sets enable_signup = true, so a subject-only key removes the only cap on one machine. A JSON-RPC route hides the verb from routing, so charge the budget inside the tool registry.
  • Log a named field per tool call. One path label covers every call today, so nothing records which tool ran or who ran it. Log the tool name and the token subject, and never a request object, a header bag or a full URL. Check the logger's redaction paths against the shapes this module actually logs.

Notes

Every tool runs the access decision against the caller's own subject before any service-role call. The content, versions and changes routers are service-role only and check no caller (modules/document-content/http/router.ts:30, modules/document-versions/http/router.ts:41, modules/document-changes/http/router.ts:25). read_document needs no service-role credential at all, because GET /:documentId/export already accepts the caller's token and gates correctly.

The edge already routes it. docker-compose.prod.yml:220 carries PathPrefix(/api).

Test with bunx @modelcontextprotocol/inspector --cli http://localhost:4000/api/mcp --transport http --method tools/list before testing through a host.

docker compose --scale rest-api=2 will not work as the compose file stands, because docker-compose.dev.yml sets container_name and a fixed 4000:4000 on that service. Use two processes on two ports instead.

Adding @modelcontextprotocol/sdk is a new dependency. A warm local install proves nothing about the lockfile, so prove it with a cold install. A Docker build is the cheapest one.

Blocked by #223 and #225.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions