Skip to content

Stop advertising tools.listChanged on the MCP connector #460

Description

@HMarzban

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

  • serverFactory.ts passes capabilities: { tools: { listChanged: false } } to new McpServer, with the why-comment.
  • A server built by createServerFactory reports getCapabilities().tools as { listChanged: false }, so initialize and server/discover both 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.
  • 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.

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

    Labels

    bugSomething isn't workinggood first issueGood for newcomers

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions