Summary
The MCP connector tells connected apps that its tool list can change, but it never changes. A connected app on the current protocol may then open a subscriptions/listen stream that never carries a notification. Each such stream holds a rest-api socket for its whole life.
Parent: #230.
Where
@modelcontextprotocol/[email protected], dist/mcp-DXXb3Vv3.mjs:1379: registerCapabilities({ tools: { listChanged: …?.listChanged ?? true } }). The SDK defaults to true.
apps/hocuspocus.server/src/modules/mcp/http/serverFactory.ts:34-43: new McpServer(…, { instructions }) sets no capabilities, so the default applies.
- The tool set is fixed:
registerDocumentTools and registerChatTools run once per request (serverFactory.ts:44-45). No code disables, removes or adds a tool later.
What happens
Read from code, not measured.
- A connected app that speaks MCP revision 2026-07-28 sees
listChanged: true and may open subscriptions/listen.
- The SDK serves it as an SSE stream with a 15 s keep-alive and no end (
createListenRouter, dist/mcp-DXXb3Vv3.mjs:219-325; DEFAULT_SSE_KEEP_ALIVE_MS, :62). The stream never carries a message, and it holds a rest-api socket.
- On deploy,
server.stop() waits for open sockets up to DRAIN_TIMEOUT_MS (10 s, src/index.ts:201).
- A connected app on a 2025 revision (Claude and ChatGPT today) is not affected. The SDK's stateless fallback answers
GET with 405 (dist/index.mjs:968), so it never had a stream.
Fix
Pass the capability explicitly in serverFactory.ts:
// The tool set never changes. The SDK default (true) invites a
// subscriptions/listen stream that would never carry a message.
{ instructions: INSTRUCTIONS, capabilities: { tools: { listChanged: false } } }
An explicit false survives registerCapabilities, because ?? keeps false (:1379). This removes the reason to open a stream; it does not refuse one. The SDK still serves subscriptions/listen, but with the capability off its ack carries an empty notification set (honoredSubset, :160-168).
Out of scope
- Refusing
subscriptions/listen.
- Docs.
API.md, docs/mcp/ and the server CLAUDE.md do not mention this capability, so nothing changes there.
Acceptance criteria
Verify
cd apps/hocuspocus.server && bun run typecheck && bun test. No existing test covers this line; the run only guards against breakage.
- Throwaway check, not committed. From
apps/hocuspocus.server, run a scratch script with bun --env-file=../../.env.local. It builds one server through createServerFactory(stubDeps)({ authInfo: { token: 't', clientId: 'c', scopes: [], extra: { sub: 'u' } } }) and prints server.server.getCapabilities().tools. Expect { listChanged: false }. Before the fix it prints { listChanged: true }. Stub deps are enough, because tool registration does not call them. Read from code, not run.
- That one value is the whole answer.
initialize returns getCapabilities() (dist/mcp-DXXb3Vv3.mjs:1023), and server/discover returns a plain copy of it (discoverAdvertisedCapabilities, :1291). So the check needs no connected-app token.
- After deploy: Claude and ChatGPT still list all ten tools.
Summary
The MCP connector tells connected apps that its tool list can change, but it never changes. A connected app on the current protocol may then open a
subscriptions/listenstream that never carries a notification. Each such stream holds arest-apisocket for its whole life.Parent: #230.
Where
@modelcontextprotocol/[email protected],dist/mcp-DXXb3Vv3.mjs:1379:registerCapabilities({ tools: { listChanged: …?.listChanged ?? true } }). The SDK defaults totrue.apps/hocuspocus.server/src/modules/mcp/http/serverFactory.ts:34-43:new McpServer(…, { instructions })sets no capabilities, so the default applies.registerDocumentToolsandregisterChatToolsrun once per request (serverFactory.ts:44-45). No code disables, removes or adds a tool later.What happens
Read from code, not measured.
listChanged: trueand may opensubscriptions/listen.createListenRouter,dist/mcp-DXXb3Vv3.mjs:219-325;DEFAULT_SSE_KEEP_ALIVE_MS,:62). The stream never carries a message, and it holds arest-apisocket.server.stop()waits for open sockets up toDRAIN_TIMEOUT_MS(10 s,src/index.ts:201).GETwith 405 (dist/index.mjs:968), so it never had a stream.Fix
Pass the capability explicitly in
serverFactory.ts:An explicit
falsesurvivesregisterCapabilities, because??keepsfalse(:1379). This removes the reason to open a stream; it does not refuse one. The SDK still servessubscriptions/listen, but with the capability off its ack carries an empty notification set (honoredSubset,:160-168).Out of scope
subscriptions/listen.API.md,docs/mcp/and the serverCLAUDE.mddo not mention this capability, so nothing changes there.Acceptance criteria
serverFactory.tspassescapabilities: { tools: { listChanged: false } }tonew McpServer, with the why-comment.createServerFactoryreportsgetCapabilities().toolsas{ listChanged: false }, soinitializeandserver/discoverboth report it (see Verify).Verify
cd apps/hocuspocus.server && bun run typecheck && bun test. No existing test covers this line; the run only guards against breakage.apps/hocuspocus.server, run a scratch script withbun --env-file=../../.env.local. It builds one server throughcreateServerFactory(stubDeps)({ authInfo: { token: 't', clientId: 'c', scopes: [], extra: { sub: 'u' } } })and printsserver.server.getCapabilities().tools. Expect{ listChanged: false }. Before the fix it prints{ listChanged: true }. Stub deps are enough, because tool registration does not call them. Read from code, not run.initializereturnsgetCapabilities()(dist/mcp-DXXb3Vv3.mjs:1023), andserver/discoverreturns a plain copy of it (discoverAdvertisedCapabilities,:1291). So the check needs no connected-app token.