From f1377e977e2758c73deedfbca21600279477607e Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 9 Jun 2026 21:36:41 +0100 Subject: [PATCH] fix(server,client): non-SEP draft spec conformance (eager list handlers, pagination docs, path-sanitization note) - McpServer eagerly installs tools/resources/prompts handlers for capabilities declared in ServerOptions.capabilities, so declared-but-empty capabilities answer their list methods with empty results instead of -32601 (draft spec MUST) - Fix pagination doc examples to loop while (cursor !== undefined) so an empty-string cursor is not treated as end-of-results (draft spec MUST NOT) - Add path-sanitization security note to docs/server.md resources section - Add regression tests for deterministic tools/list ordering (already compliant; insertion order) - Completion 100-cap + hasMore already implemented and covered by e2e tests Closes #2202 --- .changeset/draft-spec-non-sep-conformance.md | 9 ++ docs/server.md | 3 + packages/client/src/client/client.examples.ts | 9 +- packages/client/src/client/client.ts | 9 +- packages/server/src/server/mcp.ts | 18 +++ packages/server/src/server/server.ts | 7 + .../test/server/declaredCapabilities.test.ts | 152 ++++++++++++++++++ 7 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 .changeset/draft-spec-non-sep-conformance.md create mode 100644 test/integration/test/server/declaredCapabilities.test.ts diff --git a/.changeset/draft-spec-non-sep-conformance.md b/.changeset/draft-spec-non-sep-conformance.md new file mode 100644 index 0000000000..d57d1f34d6 --- /dev/null +++ b/.changeset/draft-spec-non-sep-conformance.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/client': patch +--- + +Non-SEP draft spec conformance fixes + +- `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`). +- 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. diff --git a/docs/server.md b/docs/server.md index b16c24fc4d..a9390e0cb4 100644 --- a/docs/server.md +++ b/docs/server.md @@ -252,6 +252,9 @@ server.registerResource( ); ``` +> [!IMPORTANT] +> **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. + ## Prompts 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. diff --git a/packages/client/src/client/client.examples.ts b/packages/client/src/client/client.examples.ts index b08694cfbd..0789b1501a 100644 --- a/packages/client/src/client/client.examples.ts +++ b/packages/client/src/client/client.examples.ts @@ -143,11 +143,12 @@ async function Client_listTools_pagination(client: Client) { //#region Client_listTools_pagination const allTools: Tool[] = []; let cursor: string | undefined; + // Note: an empty-string cursor is valid and does not signal the end of results. do { const { tools, nextCursor } = await client.listTools({ cursor }); allTools.push(...tools); cursor = nextCursor; - } while (cursor); + } while (cursor !== undefined); console.log( 'Available tools:', allTools.map(t => t.name) @@ -162,11 +163,12 @@ async function Client_listPrompts_pagination(client: Client) { //#region Client_listPrompts_pagination const allPrompts: Prompt[] = []; let cursor: string | undefined; + // Note: an empty-string cursor is valid and does not signal the end of results. do { const { prompts, nextCursor } = await client.listPrompts({ cursor }); allPrompts.push(...prompts); cursor = nextCursor; - } while (cursor); + } while (cursor !== undefined); console.log( 'Available prompts:', allPrompts.map(p => p.name) @@ -181,11 +183,12 @@ async function Client_listResources_pagination(client: Client) { //#region Client_listResources_pagination const allResources: Resource[] = []; let cursor: string | undefined; + // Note: an empty-string cursor is valid and does not signal the end of results. do { const { resources, nextCursor } = await client.listResources({ cursor }); allResources.push(...resources); cursor = nextCursor; - } while (cursor); + } while (cursor !== undefined); console.log( 'Available resources:', allResources.map(r => r.name) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 36a98521cd..3a52fef578 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -657,11 +657,12 @@ export class Client extends Protocol { * ```ts source="./client.examples.ts#Client_listPrompts_pagination" * const allPrompts: Prompt[] = []; * let cursor: string | undefined; + * // Note: an empty-string cursor is valid and does not signal the end of results. * do { * const { prompts, nextCursor } = await client.listPrompts({ cursor }); * allPrompts.push(...prompts); * cursor = nextCursor; - * } while (cursor); + * } while (cursor !== undefined); * console.log( * 'Available prompts:', * allPrompts.map(p => p.name) @@ -687,11 +688,12 @@ export class Client extends Protocol { * ```ts source="./client.examples.ts#Client_listResources_pagination" * const allResources: Resource[] = []; * let cursor: string | undefined; + * // Note: an empty-string cursor is valid and does not signal the end of results. * do { * const { resources, nextCursor } = await client.listResources({ cursor }); * allResources.push(...resources); * cursor = nextCursor; - * } while (cursor); + * } while (cursor !== undefined); * console.log( * 'Available resources:', * allResources.map(r => r.name) @@ -850,11 +852,12 @@ export class Client extends Protocol { * ```ts source="./client.examples.ts#Client_listTools_pagination" * const allTools: Tool[] = []; * let cursor: string | undefined; + * // Note: an empty-string cursor is valid and does not signal the end of results. * do { * const { tools, nextCursor } = await client.listTools({ cursor }); * allTools.push(...tools); * cursor = nextCursor; - * } while (cursor); + * } while (cursor !== undefined); * console.log( * 'Available tools:', * allTools.map(t => t.name) diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 40ec8bb1eb..d437cd70a1 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -70,6 +70,21 @@ export class McpServer { constructor(serverInfo: Implementation, options?: ServerOptions) { this.server = new Server(serverInfo, options); + + // Per the MCP spec, a server that declares a primitive capability MUST respond to its + // list method (potentially with an empty result) rather than "Method not found" — even + // if nothing has been registered yet. Handlers are normally installed lazily on first + // registration, so eagerly install them here for any capability declared up front. + // (Users of the low-level `Server` class remain responsible for their own handlers.) + if (options?.capabilities?.tools) { + this.setToolRequestHandlers(); + } + if (options?.capabilities?.resources) { + this.setResourceRequestHandlers(); + } + if (options?.capabilities?.prompts) { + this.setPromptRequestHandlers(); + } } /** @@ -111,6 +126,9 @@ export class McpServer { } }); + // Note: tools are listed in registration (insertion) order, which keeps the ordering + // deterministic across requests when the underlying tool set has not changed, as + // recommended by the spec. this.server.setRequestHandler( 'tools/list', (): ListToolsResult => ({ diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 9e117f05c3..dcc29d5e62 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -54,6 +54,13 @@ import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims' export type ServerOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this server. + * + * Note: per the MCP spec, a server that declares a capability MUST respond to that + * capability's requests (e.g. `tools/list` for `tools`) — potentially with an empty + * result — rather than with a "Method not found" error. {@linkcode server/mcp.McpServer | McpServer} + * handles this automatically for capabilities declared here; when using the low-level + * {@linkcode Server} directly, you are responsible for registering a request handler for + * every capability you declare. */ capabilities?: ServerCapabilities; diff --git a/test/integration/test/server/declaredCapabilities.test.ts b/test/integration/test/server/declaredCapabilities.test.ts new file mode 100644 index 0000000000..60e3f847bb --- /dev/null +++ b/test/integration/test/server/declaredCapabilities.test.ts @@ -0,0 +1,152 @@ +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport, ProtocolErrorCode } from '@modelcontextprotocol/core'; +import { McpServer } from '@modelcontextprotocol/server'; +import { describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +async function connect(mcpServer: McpServer): Promise { + const client = new Client({ name: 'test client', version: '1.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + return client; +} + +describe('declared capabilities answer list methods (draft spec)', () => { + /*** + * Test: a server that declares a primitive capability MUST respond to its list method + * (with an empty result) even if nothing has been registered yet, rather than + * returning "Method not found". + */ + test('declared-but-empty tools/resources/prompts capabilities answer list methods with empty arrays', async () => { + const mcpServer = new McpServer( + { name: 'test server', version: '1.0' }, + { capabilities: { tools: {}, resources: {}, prompts: {} } } + ); + + const client = await connect(mcpServer); + + await expect(client.listTools()).resolves.toEqual({ tools: [] }); + await expect(client.listResources()).resolves.toEqual({ resources: [] }); + await expect(client.listResourceTemplates()).resolves.toEqual({ resourceTemplates: [] }); + await expect(client.listPrompts()).resolves.toEqual({ prompts: [] }); + }); + + /*** + * Test: calling an unknown tool on a declared-but-empty tools capability returns + * an "Invalid params" error, not "Method not found". + */ + test('tools/call for an unknown tool returns InvalidParams when tools capability is declared', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }, { capabilities: { tools: {} } }); + + const client = await connect(mcpServer); + + await expect(client.callTool({ name: 'nonexistent' })).rejects.toMatchObject({ + code: ProtocolErrorCode.InvalidParams + }); + }); + + /*** + * Test: capabilities that were NOT declared (and have no registrations) still return + * "Method not found" on the wire. Raw requests are used because the Client's + * convenience list methods short-circuit locally when the server does not advertise + * the corresponding capability. + */ + test('undeclared capabilities still return MethodNotFound', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }, { capabilities: { tools: {} } }); + + const client = await connect(mcpServer); + + await expect(client.listTools()).resolves.toEqual({ tools: [] }); + await expect(client.request({ method: 'resources/list' })).rejects.toMatchObject({ + code: ProtocolErrorCode.MethodNotFound + }); + await expect(client.request({ method: 'prompts/list' })).rejects.toMatchObject({ + code: ProtocolErrorCode.MethodNotFound + }); + }); + + /*** + * Test: a server constructed without declared capabilities behaves as before — + * list handlers are installed lazily on first registration. + */ + test('no declared capabilities and no registrations returns MethodNotFound for all list methods', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }); + + const client = await connect(mcpServer); + + await expect(client.request({ method: 'tools/list' })).rejects.toMatchObject({ + code: ProtocolErrorCode.MethodNotFound + }); + await expect(client.request({ method: 'resources/list' })).rejects.toMatchObject({ + code: ProtocolErrorCode.MethodNotFound + }); + await expect(client.request({ method: 'prompts/list' })).rejects.toMatchObject({ + code: ProtocolErrorCode.MethodNotFound + }); + }); + + /*** + * Test: registering primitives after declaring the capability up front continues to work + * (the eagerly installed handlers list later registrations). + */ + test('registrations made after construction are listed by the eagerly installed handlers', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }, { capabilities: { tools: {} } }); + + mcpServer.registerTool('greet', { description: 'Greets' }, () => ({ + content: [{ type: 'text', text: 'hi' }] + })); + + const client = await connect(mcpServer); + + const result = await client.listTools(); + expect(result.tools.map(t => t.name)).toEqual(['greet']); + }); +}); + +describe('deterministic tools/list ordering (draft spec)', () => { + /*** + * Test: tools/list SHOULD return tools in a deterministic order when the underlying + * tool set has not changed. The SDK lists tools in registration (insertion) order. + */ + test('tools/list returns an identical order across repeated requests', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }); + + const names = ['zeta', 'alpha', 'mid', 'omega', 'beta']; + for (const name of names) { + mcpServer.registerTool(name, { inputSchema: z.object({ value: z.string() }) }, ({ value }) => ({ + content: [{ type: 'text', text: `${name}:${value}` }] + })); + } + + const client = await connect(mcpServer); + + const first = await client.listTools(); + const second = await client.listTools(); + + expect(first.tools.map(t => t.name)).toEqual(names); + expect(second.tools.map(t => t.name)).toEqual(names); + }); + + test('tools/list ordering stays stable across disable/enable toggles', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }); + + const names = ['zeta', 'alpha', 'mid', 'omega', 'beta']; + const registered = names.map(name => + mcpServer.registerTool(name, {}, () => ({ + content: [{ type: 'text', text: name }] + })) + ); + + const client = await connect(mcpServer); + + // Disable a tool in the middle: relative order of the remaining tools is unchanged. + registered[2].disable(); + const whileDisabled = await client.listTools(); + expect(whileDisabled.tools.map(t => t.name)).toEqual(['zeta', 'alpha', 'omega', 'beta']); + + // Re-enable it: the original insertion order is restored, not appended at the end. + registered[2].enable(); + const afterReenable = await client.listTools(); + expect(afterReenable.tools.map(t => t.name)).toEqual(names); + }); +});