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
9 changes: 9 additions & 0 deletions .changeset/draft-spec-non-sep-conformance.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 6 additions & 3 deletions packages/client/src/client/client.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
9 changes: 6 additions & 3 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,11 +657,12 @@ export class Client extends Protocol<ClientContext> {
* ```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)
Expand All @@ -687,11 +688,12 @@ export class Client extends Protocol<ClientContext> {
* ```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)
Expand Down Expand Up @@ -850,11 +852,12 @@ export class Client extends Protocol<ClientContext> {
* ```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)
Expand Down
18 changes: 18 additions & 0 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}

/**
Expand Down Expand Up @@ -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 => ({
Expand Down
7 changes: 7 additions & 0 deletions packages/server/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
152 changes: 152 additions & 0 deletions test/integration/test/server/declaredCapabilities.test.ts
Original file line number Diff line number Diff line change
@@ -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<Client> {
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);
});
});
Loading