Skip to content

Commit e84c3e9

Browse files
fix(server,client): non-SEP draft spec conformance (eager list handlers, pagination docs, path-sanitization note) (modelcontextprotocol#2269)
Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
1 parent 49c0a71 commit e84c3e9

7 files changed

Lines changed: 201 additions & 6 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@modelcontextprotocol/server': patch
3+
'@modelcontextprotocol/client': patch
4+
---
5+
6+
Non-SEP draft spec conformance fixes
7+
8+
- `McpServer` now eagerly installs list/read/call handlers for every primitive capability (`tools`, `resources`, `prompts`) declared in `ServerOptions.capabilities`. Per the draft spec, a server that declares a capability MUST respond to its list method (potentially with an empty result) instead of returning "Method not found". Previously, handlers were only installed lazily on first registration, so a server constructed with e.g. `capabilities: { tools: {} }` and zero registered tools answered `tools/list` with `-32601`. Low-level `Server` users remain responsible for registering handlers for declared capabilities (documented on `ServerOptions.capabilities`).
9+
- Fixed pagination doc examples on `Client.listTools`/`listPrompts`/`listResources` to loop `while (cursor !== undefined)` instead of `while (cursor)` — per the draft spec, clients MUST NOT treat an empty-string cursor as the end of results.

docs/server.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,9 @@ server.registerResource(
252252
);
253253
```
254254

255+
> [!IMPORTANT]
256+
> **Security note:** If a resource is backed by the filesystem (for example, a `file://` server or a template whose variables map onto file paths), the spec requires sanitizing any user-influenced path before use. Resolve the requested path and verify it stays within the intended root directory, rejecting traversal sequences such as `..` (including encoded forms) and symlinks that escape the root. Never pass template variables or client-supplied URIs to filesystem APIs unchecked.
257+
255258
## Prompts
256259

257260
Prompts are reusable templates that help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). Use a prompt when you want to offer a canned interaction pattern that users invoke explicitly; use a [tool](#tools) when the LLM should decide when to call it.

packages/client/src/client/client.examples.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,12 @@ async function Client_listTools_pagination(client: Client) {
143143
//#region Client_listTools_pagination
144144
const allTools: Tool[] = [];
145145
let cursor: string | undefined;
146+
// Note: an empty-string cursor is valid and does not signal the end of results.
146147
do {
147148
const { tools, nextCursor } = await client.listTools({ cursor });
148149
allTools.push(...tools);
149150
cursor = nextCursor;
150-
} while (cursor);
151+
} while (cursor !== undefined);
151152
console.log(
152153
'Available tools:',
153154
allTools.map(t => t.name)
@@ -162,11 +163,12 @@ async function Client_listPrompts_pagination(client: Client) {
162163
//#region Client_listPrompts_pagination
163164
const allPrompts: Prompt[] = [];
164165
let cursor: string | undefined;
166+
// Note: an empty-string cursor is valid and does not signal the end of results.
165167
do {
166168
const { prompts, nextCursor } = await client.listPrompts({ cursor });
167169
allPrompts.push(...prompts);
168170
cursor = nextCursor;
169-
} while (cursor);
171+
} while (cursor !== undefined);
170172
console.log(
171173
'Available prompts:',
172174
allPrompts.map(p => p.name)
@@ -181,11 +183,12 @@ async function Client_listResources_pagination(client: Client) {
181183
//#region Client_listResources_pagination
182184
const allResources: Resource[] = [];
183185
let cursor: string | undefined;
186+
// Note: an empty-string cursor is valid and does not signal the end of results.
184187
do {
185188
const { resources, nextCursor } = await client.listResources({ cursor });
186189
allResources.push(...resources);
187190
cursor = nextCursor;
188-
} while (cursor);
191+
} while (cursor !== undefined);
189192
console.log(
190193
'Available resources:',
191194
allResources.map(r => r.name)

packages/client/src/client/client.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -677,11 +677,12 @@ export class Client extends Protocol<ClientContext> {
677677
* ```ts source="./client.examples.ts#Client_listPrompts_pagination"
678678
* const allPrompts: Prompt[] = [];
679679
* let cursor: string | undefined;
680+
* // Note: an empty-string cursor is valid and does not signal the end of results.
680681
* do {
681682
* const { prompts, nextCursor } = await client.listPrompts({ cursor });
682683
* allPrompts.push(...prompts);
683684
* cursor = nextCursor;
684-
* } while (cursor);
685+
* } while (cursor !== undefined);
685686
* console.log(
686687
* 'Available prompts:',
687688
* allPrompts.map(p => p.name)
@@ -707,11 +708,12 @@ export class Client extends Protocol<ClientContext> {
707708
* ```ts source="./client.examples.ts#Client_listResources_pagination"
708709
* const allResources: Resource[] = [];
709710
* let cursor: string | undefined;
711+
* // Note: an empty-string cursor is valid and does not signal the end of results.
710712
* do {
711713
* const { resources, nextCursor } = await client.listResources({ cursor });
712714
* allResources.push(...resources);
713715
* cursor = nextCursor;
714-
* } while (cursor);
716+
* } while (cursor !== undefined);
715717
* console.log(
716718
* 'Available resources:',
717719
* allResources.map(r => r.name)
@@ -870,11 +872,12 @@ export class Client extends Protocol<ClientContext> {
870872
* ```ts source="./client.examples.ts#Client_listTools_pagination"
871873
* const allTools: Tool[] = [];
872874
* let cursor: string | undefined;
875+
* // Note: an empty-string cursor is valid and does not signal the end of results.
873876
* do {
874877
* const { tools, nextCursor } = await client.listTools({ cursor });
875878
* allTools.push(...tools);
876879
* cursor = nextCursor;
877-
* } while (cursor);
880+
* } while (cursor !== undefined);
878881
* console.log(
879882
* 'Available tools:',
880883
* allTools.map(t => t.name)

packages/server/src/server/mcp.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,21 @@ export class McpServer {
7070

7171
constructor(serverInfo: Implementation, options?: ServerOptions) {
7272
this.server = new Server(serverInfo, options);
73+
74+
// Per the MCP spec, a server that declares a primitive capability MUST respond to its
75+
// list method (potentially with an empty result) rather than "Method not found" — even
76+
// if nothing has been registered yet. Handlers are normally installed lazily on first
77+
// registration, so eagerly install them here for any capability declared up front.
78+
// (Users of the low-level `Server` class remain responsible for their own handlers.)
79+
if (options?.capabilities?.tools) {
80+
this.setToolRequestHandlers();
81+
}
82+
if (options?.capabilities?.resources) {
83+
this.setResourceRequestHandlers();
84+
}
85+
if (options?.capabilities?.prompts) {
86+
this.setPromptRequestHandlers();
87+
}
7388
}
7489

7590
/**
@@ -111,6 +126,9 @@ export class McpServer {
111126
}
112127
});
113128

129+
// Note: tools are listed in registration (insertion) order, which keeps the ordering
130+
// deterministic across requests when the underlying tool set has not changed, as
131+
// recommended by the spec.
114132
this.server.setRequestHandler(
115133
'tools/list',
116134
(): ListToolsResult => ({

packages/server/src/server/server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'
5454
export type ServerOptions = ProtocolOptions & {
5555
/**
5656
* Capabilities to advertise as being supported by this server.
57+
*
58+
* Note: per the MCP spec, a server that declares a capability MUST respond to that
59+
* capability's requests (e.g. `tools/list` for `tools`) — potentially with an empty
60+
* result — rather than with a "Method not found" error. {@linkcode server/mcp.McpServer | McpServer}
61+
* handles this automatically for capabilities declared here; when using the low-level
62+
* {@linkcode Server} directly, you are responsible for registering a request handler for
63+
* every capability you declare.
5764
*/
5865
capabilities?: ServerCapabilities;
5966

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { Client } from '@modelcontextprotocol/client';
2+
import { InMemoryTransport, ProtocolErrorCode } from '@modelcontextprotocol/core';
3+
import { McpServer } from '@modelcontextprotocol/server';
4+
import { describe, expect, test } from 'vitest';
5+
import * as z from 'zod/v4';
6+
7+
async function connect(mcpServer: McpServer): Promise<Client> {
8+
const client = new Client({ name: 'test client', version: '1.0' });
9+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
10+
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
11+
return client;
12+
}
13+
14+
describe('declared capabilities answer list methods (draft spec)', () => {
15+
/***
16+
* Test: a server that declares a primitive capability MUST respond to its list method
17+
* (with an empty result) even if nothing has been registered yet, rather than
18+
* returning "Method not found".
19+
*/
20+
test('declared-but-empty tools/resources/prompts capabilities answer list methods with empty arrays', async () => {
21+
const mcpServer = new McpServer(
22+
{ name: 'test server', version: '1.0' },
23+
{ capabilities: { tools: {}, resources: {}, prompts: {} } }
24+
);
25+
26+
const client = await connect(mcpServer);
27+
28+
await expect(client.listTools()).resolves.toEqual({ tools: [] });
29+
await expect(client.listResources()).resolves.toEqual({ resources: [] });
30+
await expect(client.listResourceTemplates()).resolves.toEqual({ resourceTemplates: [] });
31+
await expect(client.listPrompts()).resolves.toEqual({ prompts: [] });
32+
});
33+
34+
/***
35+
* Test: calling an unknown tool on a declared-but-empty tools capability returns
36+
* an "Invalid params" error, not "Method not found".
37+
*/
38+
test('tools/call for an unknown tool returns InvalidParams when tools capability is declared', async () => {
39+
const mcpServer = new McpServer({ name: 'test server', version: '1.0' }, { capabilities: { tools: {} } });
40+
41+
const client = await connect(mcpServer);
42+
43+
await expect(client.callTool({ name: 'nonexistent' })).rejects.toMatchObject({
44+
code: ProtocolErrorCode.InvalidParams
45+
});
46+
});
47+
48+
/***
49+
* Test: capabilities that were NOT declared (and have no registrations) still return
50+
* "Method not found" on the wire. Raw requests are used because the Client's
51+
* convenience list methods short-circuit locally when the server does not advertise
52+
* the corresponding capability.
53+
*/
54+
test('undeclared capabilities still return MethodNotFound', async () => {
55+
const mcpServer = new McpServer({ name: 'test server', version: '1.0' }, { capabilities: { tools: {} } });
56+
57+
const client = await connect(mcpServer);
58+
59+
await expect(client.listTools()).resolves.toEqual({ tools: [] });
60+
await expect(client.request({ method: 'resources/list' })).rejects.toMatchObject({
61+
code: ProtocolErrorCode.MethodNotFound
62+
});
63+
await expect(client.request({ method: 'prompts/list' })).rejects.toMatchObject({
64+
code: ProtocolErrorCode.MethodNotFound
65+
});
66+
});
67+
68+
/***
69+
* Test: a server constructed without declared capabilities behaves as before —
70+
* list handlers are installed lazily on first registration.
71+
*/
72+
test('no declared capabilities and no registrations returns MethodNotFound for all list methods', async () => {
73+
const mcpServer = new McpServer({ name: 'test server', version: '1.0' });
74+
75+
const client = await connect(mcpServer);
76+
77+
await expect(client.request({ method: 'tools/list' })).rejects.toMatchObject({
78+
code: ProtocolErrorCode.MethodNotFound
79+
});
80+
await expect(client.request({ method: 'resources/list' })).rejects.toMatchObject({
81+
code: ProtocolErrorCode.MethodNotFound
82+
});
83+
await expect(client.request({ method: 'prompts/list' })).rejects.toMatchObject({
84+
code: ProtocolErrorCode.MethodNotFound
85+
});
86+
});
87+
88+
/***
89+
* Test: registering primitives after declaring the capability up front continues to work
90+
* (the eagerly installed handlers list later registrations).
91+
*/
92+
test('registrations made after construction are listed by the eagerly installed handlers', async () => {
93+
const mcpServer = new McpServer({ name: 'test server', version: '1.0' }, { capabilities: { tools: {} } });
94+
95+
mcpServer.registerTool('greet', { description: 'Greets' }, () => ({
96+
content: [{ type: 'text', text: 'hi' }]
97+
}));
98+
99+
const client = await connect(mcpServer);
100+
101+
const result = await client.listTools();
102+
expect(result.tools.map(t => t.name)).toEqual(['greet']);
103+
});
104+
});
105+
106+
describe('deterministic tools/list ordering (draft spec)', () => {
107+
/***
108+
* Test: tools/list SHOULD return tools in a deterministic order when the underlying
109+
* tool set has not changed. The SDK lists tools in registration (insertion) order.
110+
*/
111+
test('tools/list returns an identical order across repeated requests', async () => {
112+
const mcpServer = new McpServer({ name: 'test server', version: '1.0' });
113+
114+
const names = ['zeta', 'alpha', 'mid', 'omega', 'beta'];
115+
for (const name of names) {
116+
mcpServer.registerTool(name, { inputSchema: z.object({ value: z.string() }) }, ({ value }) => ({
117+
content: [{ type: 'text', text: `${name}:${value}` }]
118+
}));
119+
}
120+
121+
const client = await connect(mcpServer);
122+
123+
const first = await client.listTools();
124+
const second = await client.listTools();
125+
126+
expect(first.tools.map(t => t.name)).toEqual(names);
127+
expect(second.tools.map(t => t.name)).toEqual(names);
128+
});
129+
130+
test('tools/list ordering stays stable across disable/enable toggles', async () => {
131+
const mcpServer = new McpServer({ name: 'test server', version: '1.0' });
132+
133+
const names = ['zeta', 'alpha', 'mid', 'omega', 'beta'];
134+
const registered = names.map(name =>
135+
mcpServer.registerTool(name, {}, () => ({
136+
content: [{ type: 'text', text: name }]
137+
}))
138+
);
139+
140+
const client = await connect(mcpServer);
141+
142+
// Disable a tool in the middle: relative order of the remaining tools is unchanged.
143+
registered[2].disable();
144+
const whileDisabled = await client.listTools();
145+
expect(whileDisabled.tools.map(t => t.name)).toEqual(['zeta', 'alpha', 'omega', 'beta']);
146+
147+
// Re-enable it: the original insertion order is restored, not appended at the end.
148+
registered[2].enable();
149+
const afterReenable = await client.listTools();
150+
expect(afterReenable.tools.map(t => t.name)).toEqual(names);
151+
});
152+
});

0 commit comments

Comments
 (0)