Skip to content

fix: allow registering tools/resources/prompts after connect when capabilities pre-declared (#893)#1666

Open
Vadaski wants to merge 4 commits intomodelcontextprotocol:mainfrom
Vadaski:fix/893-post-connect-registration
Open

fix: allow registering tools/resources/prompts after connect when capabilities pre-declared (#893)#1666
Vadaski wants to merge 4 commits intomodelcontextprotocol:mainfrom
Vadaski:fix/893-post-connect-registration

Conversation

@Vadaski
Copy link
Copy Markdown

@Vadaski Vadaski commented Mar 12, 2026

Summary

Fixes #893.

When McpServer is constructed with pre-declared capabilities (e.g. { capabilities: { tools: { listChanged: true } } }), calling registerTool() / registerResource() / registerPrompt() after connect() used to throw:

SdkError: Cannot register capabilities after connecting to transport

Because the first handler registration always called server.registerCapabilities() unconditionally, which fails post-connect.

Root Cause

setToolRequestHandlers(), setResourceRequestHandlers(), setPromptRequestHandlers(), and setCompletionRequestHandler() all unconditionally called this.server.registerCapabilities() on first invocation — even when those capabilities were already declared at construction time.

Fix

  • Introduce withDefaultListChangedCapabilities() helper that applies listChanged defaults when capabilities are pre-declared in options.
  • When capabilities are pre-declared, eagerly call the corresponding set*RequestHandlers() in the McpServer constructor (before connect() is ever called), so the handlers are already registered when the user later calls register*() after connect.
  • Guard every registerCapabilities() call inside set*RequestHandlers() and setCompletionRequestHandler() with !this.server.transport, so post-connect invocations skip the now-redundant re-registration.

Test Plan

  • New regression test file test/integration/test/issues/test893.post-connect-registration.test.ts — 5 tests covering:
    1. Register tool after connect when capabilities.tools pre-declared
    2. Register resource after connect when capabilities.resources pre-declared
    3. Register prompt after connect when capabilities.prompts pre-declared
    4. Register completable prompt after connect (tests setCompletionRequestHandler guard)
    5. Register completable resource template after connect
  • All 391 existing integration tests pass (pnpm test:all)
  • pnpm check:all passes (typecheck + lint)

🤖 Generated with Claude Code

…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>
@Vadaski Vadaski requested a review from a team as a code owner March 12, 2026 05:32
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 12, 2026

⚠️ No Changeset found

Latest commit: 0257fe3

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Mar 12, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1666

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1666

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1666

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@1666

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1666

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1666

commit: 0257fe3

Vadaski and others added 2 commits March 11, 2026 22:34
…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>
Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@km-anthropic
Copy link
Copy Markdown

@claude review

1 similar comment
@felixweinberger
Copy link
Copy Markdown
Contributor

@claude review

Comment on lines 358 to 368

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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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:

  1. assertCanSetRequestHandler('completion/complete') (protocol.ts:1031) only checks if a handler already exists. Passes, since none was registered.
  2. if (!this.server.transport) evaluates false post-connect, so registerCapabilities({ completions: {} }) is skipped (mcp.ts:361-365).
  3. this.server.setRequestHandler('completion/complete', ...) (protocol.ts:1008) immediately calls assertRequestHandlerCapability('completion/complete') (protocol.ts:1012), which at server.ts:372 checks this._capabilities.completions. It is not set so it throws SdkError: "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.

Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comments above nothing new, just re-requesting changes to remove from my review queue.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

McpServer re-registers capabilities after connect, blocking dynamic registration even when capabilities were supplied at construction

3 participants