Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 58 additions & 19 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,18 @@ export class McpServer {
private _experimental?: { tasks: ExperimentalMcpServerTasks };

constructor(serverInfo: Implementation, options?: ServerOptions) {
this.server = new Server(serverInfo, options);
this.server = new Server(serverInfo, withDefaultListChangedCapabilities(options));

const capabilities = this.server.getCapabilities();
if (capabilities.tools) {
this.setToolRequestHandlers();
}
if (capabilities.resources) {
this.setResourceRequestHandlers();
}
if (capabilities.prompts) {
this.setPromptRequestHandlers();
}
}

/**
Expand Down Expand Up @@ -125,11 +136,13 @@ export class McpServer {
this.server.assertCanSetRequestHandler('tools/list');
this.server.assertCanSetRequestHandler('tools/call');

this.server.registerCapabilities({
tools: {
listChanged: this.server.getCapabilities().tools?.listChanged ?? true
}
});
if (!this.server.transport) {
this.server.registerCapabilities({
tools: {
listChanged: this.server.getCapabilities().tools?.listChanged ?? true
}
});
}

this.server.setRequestHandler(
'tools/list',
Expand Down Expand Up @@ -345,9 +358,11 @@ export class McpServer {

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) {
Comment on lines 358 to 368
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.

Expand Down Expand Up @@ -434,11 +449,13 @@ export class McpServer {
this.server.assertCanSetRequestHandler('resources/templates/list');
this.server.assertCanSetRequestHandler('resources/read');

this.server.registerCapabilities({
resources: {
listChanged: this.server.getCapabilities().resources?.listChanged ?? true
}
});
if (!this.server.transport) {
this.server.registerCapabilities({
resources: {
listChanged: this.server.getCapabilities().resources?.listChanged ?? true
}
});
}

this.server.setRequestHandler('resources/list', async (_request, ctx) => {
const resources = Object.entries(this._registeredResources)
Expand Down Expand Up @@ -514,11 +531,13 @@ export class McpServer {
this.server.assertCanSetRequestHandler('prompts/list');
this.server.assertCanSetRequestHandler('prompts/get');

this.server.registerCapabilities({
prompts: {
listChanged: this.server.getCapabilities().prompts?.listChanged ?? true
}
});
if (!this.server.transport) {
this.server.registerCapabilities({
prompts: {
listChanged: this.server.getCapabilities().prompts?.listChanged ?? true
}
});
}

this.server.setRequestHandler(
'prompts/list',
Expand Down Expand Up @@ -1004,6 +1023,26 @@ export class McpServer {
}
}

function withDefaultListChangedCapabilities(options?: ServerOptions): ServerOptions | undefined {
if (!options?.capabilities) {
return options;
}

const { capabilities } = options;

return {
...options,
capabilities: {
...capabilities,
...(capabilities.tools ? { tools: { ...capabilities.tools, listChanged: capabilities.tools.listChanged ?? true } } : {}),
...(capabilities.resources
? { resources: { ...capabilities.resources, listChanged: capabilities.resources.listChanged ?? true } }
: {}),
...(capabilities.prompts ? { prompts: { ...capabilities.prompts, listChanged: capabilities.prompts.listChanged ?? true } } : {})
}
};
}

/**
* A callback to complete one variable within a resource template's URI template.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { Client } from '@modelcontextprotocol/client';
import { InMemoryTransport } from '@modelcontextprotocol/core';
import { completable, McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

describe('Issue #893: post-connect registration with pre-declared capabilities', () => {
test('registers a tool after connect when capabilities.tools is pre-declared', async () => {
const server = new McpServer({ name: 'test-server', version: '1.0' }, { capabilities: { tools: { listChanged: true } } });
const client = new Client({ name: 'test-client', version: '1.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

expect(() =>
server.registerTool('echo', {}, async () => ({
content: [{ type: 'text', text: 'tool registered after connect' }]
}))
).not.toThrow();

const result = await client.callTool({ name: 'echo' });
expect(result.content).toEqual([{ type: 'text', text: 'tool registered after connect' }]);
});

test('registers a resource after connect when capabilities.resources is pre-declared', async () => {
const server = new McpServer({ name: 'test-server', version: '1.0' }, { capabilities: { resources: { listChanged: true } } });
const client = new Client({ name: 'test-client', version: '1.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

expect(() =>
server.registerResource('settings', 'test://settings', {}, async () => ({
contents: [{ uri: 'test://settings', text: 'resource registered after connect' }]
}))
).not.toThrow();

const result = await client.readResource({ uri: 'test://settings' });
expect(result.contents).toEqual([{ uri: 'test://settings', text: 'resource registered after connect' }]);
});

test('registers a prompt after connect when capabilities.prompts is pre-declared', async () => {
const server = new McpServer({ name: 'test-server', version: '1.0' }, { capabilities: { prompts: { listChanged: true } } });
const client = new Client({ name: 'test-client', version: '1.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

expect(() =>
server.registerPrompt('review', {}, async () => ({
messages: [
{
role: 'assistant',
content: { type: 'text', text: 'prompt registered after connect' }
}
]
}))
).not.toThrow();

const result = await client.request({
method: 'prompts/get',
params: { name: 'review' }
});
expect(result.messages).toEqual([
{
role: 'assistant',
content: { type: 'text', text: 'prompt registered after connect' }
}
]);
});

test('registers a completable prompt after connect when capabilities.prompts is pre-declared', async () => {
const server = new McpServer(
{ name: 'test-server', version: '1.0' },
{ capabilities: { prompts: { listChanged: true }, completions: {} } }
);
const client = new Client({ name: 'test-client', version: '1.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

expect(() =>
server.registerPrompt(
'review-with-completion',
{
argsSchema: z.object({
tone: completable(z.string(), () => ['direct', 'formal'])
})
},
async ({ tone }) => ({
messages: [
{
role: 'assistant',
content: { type: 'text', text: `tone=${tone}` }
}
]
})
)
).not.toThrow();

const result = await client.request({
method: 'completion/complete',
params: {
ref: {
type: 'ref/prompt',
name: 'review-with-completion'
},
argument: {
name: 'tone',
value: ''
}
}
});

expect(result.completion.values).toEqual(['direct', 'formal']);
expect(result.completion.total).toBe(2);
});

test('registers a completable resource template after connect when capabilities.resources is pre-declared', async () => {
const server = new McpServer(
{ name: 'test-server', version: '1.0' },
{ capabilities: { resources: { listChanged: true }, completions: {} } }
);
const client = new Client({ name: 'test-client', version: '1.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

expect(() =>
server.registerResource(
'repo',
new ResourceTemplate('test://repos/{name}', {
complete: {
name: () => ['alpha', 'beta']
}
}),
{},
async () => ({
contents: [{ uri: 'test://repos/alpha', text: 'resource registered after connect' }]
})
)
).not.toThrow();

const result = await client.request({
method: 'completion/complete',
params: {
ref: {
type: 'ref/resource',
uri: 'test://repos/{name}'
},
argument: {
name: 'name',
value: ''
}
}
});

expect(result.completion.values).toEqual(['alpha', 'beta']);
expect(result.completion.total).toBe(2);
});
});
Loading