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
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.
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.ymlruns therest-apiservice withreplicas: 2and no sticky session, while thehocuspocusservice carries five sticky-cookie labels. So the repository already sorts stateful services from stateless ones, andrest-apisits deliberately in the stateless group.Mount a stateful MCP session there, and
initializelands 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 theMcp-Session-Idheader.What to do
Mount a stateless remote streamable-HTTP MCP server inside the existing Hono app:
app.route('/api/mcp', mcpModule.router)inapps/hocuspocus.server/src/index.ts, following the house module shape used bydocument-contentanddocument-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 sawinitializeis 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 adocumentId, 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 returns401carryingWWW-Authenticate: Bearer resource_metadata="...".Acceptance
initializeon one process, thentools/liston a second process, succeeds. Two processes are two memories, which is the production condition.Stateless transport cannot be reused across requests.401with aWWW-Authenticateheader pointing at the metadata.lib/auth.ts:64-112reads noaudtoday, and RFC 8707 audience binding is a specification requirement.Originis refused with403at the mount, before any tool dispatch. Hono CORS only writes response headers and cannot refuse.readOnlyHint: true; every write tool carriesdestructiveHint: true. Hosts use these to decide auto-permission, so they are a control./api/mcpanswers 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.
@modelcontextprotocol/sdk/server/webStandardStreamableHttp.jspulls SDK-internal modules only.server/streamableHttp.jspulls a Node HTTP adapter, and anything underserver/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.metascraperandmammothare already pinned. The SDK owns the authorization boundary: the401shape, theWWW-Authenticateheader and the audience check. Take each bump as its own change, proved by the cold install this issue already requires.find_documentsscopes 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./api/mcpits 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:165setsenable_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.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_documentneeds no service-role credential at all, becauseGET /:documentId/exportalready accepts the caller's token and gates correctly.The edge already routes it.
docker-compose.prod.yml:220carriesPathPrefix(/api).Test with
bunx @modelcontextprotocol/inspector --cli http://localhost:4000/api/mcp --transport http --method tools/listbefore testing through a host.docker compose --scale rest-api=2will not work as the compose file stands, becausedocker-compose.dev.ymlsetscontainer_nameand a fixed4000:4000on that service. Use two processes on two ports instead.Adding
@modelcontextprotocol/sdkis 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.