fix: allow registering tools/resources/prompts after connect when capabilities pre-declared (#893)#1666
Conversation
…abilities pre-declared (modelcontextprotocol#893) When McpServer is constructed with pre-declared capabilities (e.g. `{ capabilities: { tools: { listChanged: true } } }`), calling `registerTool()` / `registerResource()` / `registerPrompt()` after `connect()` used to throw "Cannot register capabilities after connecting to transport" because the first handler registration always called `server.registerCapabilities()` unconditionally. Fix: - Pass capabilities through `withDefaultListChangedCapabilities()` so `listChanged` defaults are applied before `connect()`. - Eagerly initialize the corresponding request handlers in the constructor when capabilities are pre-declared, so the lazy path in `set*RequestHandlers()` is already done before `connect()`. - Guard every `registerCapabilities()` call inside the four `set*RequestHandlers()` / `setCompletionRequestHandler()` methods with `!this.server.transport`, so post-connect invocations skip the now-redundant re-registration. Fixes modelcontextprotocol#893 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@modelcontextprotocol/client
@modelcontextprotocol/server
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
…lcontextprotocol#893) { listChanged: X ?? true, ...capabilities.tools } puts the default first so the spread overwrites it — explicit undefined defeats the default. Fix to { ...capabilities.tools, listChanged: X ?? true } so the ?? true only applies when the user did not set listChanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks for picking this up.
I think withDefaultListChangedCapabilities() and the !this.server.transport guards are both redundant. Tested locally by replacing the mcp.ts changes with just this:
constructor(serverInfo: Implementation, options?: ServerOptions) {
this.server = new Server(serverInfo, options);
const capabilities = this.server.getCapabilities();
if (capabilities.tools) this.setToolRequestHandlers();
if (capabilities.resources) this.setResourceRequestHandlers();
if (capabilities.prompts) this.setPromptRequestHandlers();
if (capabilities.completions) this.setCompletionRequestHandler();
}and all new tests pass. The eager calls run before transport exists so the existing ?? true applies the default, and the _*Initialized flags mean the post-connect path never reaches registerCapabilities().
Added the completions line since otherwise a post-connect completable() with only prompts pre-declared hits the same bug.
|
@claude review |
1 similar comment
|
@claude review |
|
|
||
| this.server.assertCanSetRequestHandler('completion/complete'); | ||
|
|
||
| this.server.registerCapabilities({ | ||
| completions: {} | ||
| }); | ||
| if (!this.server.transport) { | ||
| this.server.registerCapabilities({ | ||
| completions: {} | ||
| }); | ||
| } | ||
|
|
||
| this.server.setRequestHandler('completion/complete', async (request): Promise<CompleteResult> => { | ||
| switch (request.params.ref.type) { |
There was a problem hiding this comment.
🔴 When a user pre-declares prompts or resources capability but omits completions, registering a completable prompt or resource template after connect() throws SdkError: "Server does not support completions" at registerPrompt/registerResource call time. The PR tests 4 and 5 work around this by explicitly co-declaring completions: {} alongside the primary capability, but this undocumented requirement means the natural pattern (pre-declare only prompts, then register completable prompts post-connect) fails with a misleading error rather than clear guidance to also declare completions.
Extended reasoning...
Mechanism (addressing the refutation)
The refuter correctly identifies that the failure is not silent. Here is the exact trace when setCompletionRequestHandler() is called post-connect with completions absent from pre-declared capabilities:
assertCanSetRequestHandler('completion/complete')(protocol.ts:1031) only checks if a handler already exists. Passes, since none was registered.if (!this.server.transport)evaluatesfalsepost-connect, soregisterCapabilities({ completions: {} })is skipped (mcp.ts:361-365).this.server.setRequestHandler('completion/complete', ...)(protocol.ts:1008) immediately callsassertRequestHandlerCapability('completion/complete')(protocol.ts:1012), which at server.ts:372 checksthis._capabilities.completions. It is not set so it throwsSdkError: "Server does not support completions (required for completion/complete)".
The exception propagates up through _createRegisteredPrompt to the registerPrompt caller. The handler is never registered; the client never gets to call complete(). The "silent failure" framing in the synthesis is inaccurate.
The real bug: incomplete fix with confusing error
The PR's stated goal is to allow post-connect registration when capabilities are pre-declared. For completable items this requires two pre-declared capabilities: the primary one (prompts/resources) AND completions. The constructor eagerly initialises other handlers when their capabilities are present, but omits setCompletionRequestHandler():
if (capabilities.tools) { this.setToolRequestHandlers(); }
if (capabilities.resources) { this.setResourceRequestHandlers(); }
if (capabilities.prompts) { this.setPromptRequestHandlers(); }
// missing: if (capabilities.completions) { this.setCompletionRequestHandler(); }Tests 4 and 5 work because they explicitly co-declare completions: {}, which satisfies the assertRequestHandlerCapability check at step 3. Users following tests 1-3 as a template who add completable() fields will hit the confusing error.
Step-by-step proof
const server = new McpServer(
{ name: 'test', version: '1.0' },
{ capabilities: { prompts: { listChanged: true } } } // no completions
);
await server.connect(transport);
// handshake: { prompts: { listChanged: true } } -- completions absent
server.registerPrompt('foo', {
argsSchema: z.object({ x: completable(z.string(), () => ['a','b']) })
}, async () => ({ messages: [] }));
// _createRegisteredPrompt detects completable -> setCompletionRequestHandler()
// assertCanSetRequestHandler passes (no handler yet)
// transport guard skips registerCapabilities
// setRequestHandler calls assertRequestHandlerCapability
// _capabilities.completions is undefined -> THROWS:
// SdkError: "Server does not support completions (required for completion/complete)"Error message regression
Before this PR, the same scenario threw "Cannot register capabilities after connecting to transport" -- directly actionable. After the PR, a user who pre-declares only prompts gets "Server does not support completions", which falsely implies completions are fundamentally unsupported rather than simply not pre-declared alongside prompts.
Recommended fix
Add if (capabilities.completions) { this.setCompletionRequestHandler(); } to the constructor after the existing capability checks (mcp.ts:84-87), matching the established pattern for tools/resources/prompts.
felixweinberger
left a comment
There was a problem hiding this comment.
See comments above nothing new, just re-requesting changes to remove from my review queue.
Summary
Fixes #893.
When
McpServeris constructed with pre-declared capabilities (e.g.{ capabilities: { tools: { listChanged: true } } }), callingregisterTool()/registerResource()/registerPrompt()afterconnect()used to throw:Because the first handler registration always called
server.registerCapabilities()unconditionally, which fails post-connect.Root Cause
setToolRequestHandlers(),setResourceRequestHandlers(),setPromptRequestHandlers(), andsetCompletionRequestHandler()all unconditionally calledthis.server.registerCapabilities()on first invocation — even when those capabilities were already declared at construction time.Fix
withDefaultListChangedCapabilities()helper that applieslistChangeddefaults when capabilities are pre-declared inoptions.set*RequestHandlers()in theMcpServerconstructor (beforeconnect()is ever called), so the handlers are already registered when the user later callsregister*()after connect.registerCapabilities()call insideset*RequestHandlers()andsetCompletionRequestHandler()with!this.server.transport, so post-connect invocations skip the now-redundant re-registration.Test Plan
test/integration/test/issues/test893.post-connect-registration.test.ts— 5 tests covering:capabilities.toolspre-declaredcapabilities.resourcespre-declaredcapabilities.promptspre-declaredsetCompletionRequestHandlerguard)pnpm test:all)pnpm check:allpasses (typecheck + lint)🤖 Generated with Claude Code