From cd78c0f85e267702e9014e5d0d5e5630b6deaa4b Mon Sep 17 00:00:00 2001 From: DDT <141296842+Hugo-DDT@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:06:18 +0800 Subject: [PATCH 1/2] fix(server): use null-prototype maps for McpServer registries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain `{}` registries treat Object.prototype names (constructor, toString, __proto__, …) as already registered, so proxies that expose such upstream tools fail at registration. Initialize tool/prompt/resource registries with Object.create(null) and cover the names with red-green tests. Fixes #3019 --- .changeset/null-prototype-mcp-registries.md | 5 + packages/server/src/server/mcp.ts | 12 ++- .../server/registerPrototypeNames.test.ts | 92 +++++++++++++++++++ 3 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 .changeset/null-prototype-mcp-registries.md create mode 100644 packages/server/test/server/registerPrototypeNames.test.ts diff --git a/.changeset/null-prototype-mcp-registries.md b/.changeset/null-prototype-mcp-registries.md new file mode 100644 index 0000000000..26c5899b28 --- /dev/null +++ b/.changeset/null-prototype-mcp-registries.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +McpServer tool/prompt/resource registries now use null-prototype objects so names inherited from `Object.prototype` (`constructor`, `toString`, `__proto__`, …) can be registered, listed, and called like any other name. diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index bfb3b798b0..b5751d9c59 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -117,14 +117,16 @@ export class McpServer { private readonly _maxToolInputElements: number | undefined; - private _registeredResources: { [uri: string]: RegisteredResource } = {}; + // Null-prototype maps so names inherited from Object.prototype + // (constructor, toString, __proto__, …) are not treated as already registered. + private _registeredResources: { [uri: string]: RegisteredResource } = Object.create(null); private _registeredResourceTemplates: { [name: string]: RegisteredResourceTemplate; - } = {}; - private _registeredTools: { [name: string]: RegisteredTool } = {}; - private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; + } = Object.create(null); + private _registeredTools: { [name: string]: RegisteredTool } = Object.create(null); + private _registeredPrompts: { [name: string]: RegisteredPrompt } = Object.create(null); /** Per-tool JSON-converted `inputSchema`, filled on first use by `toolInputSchemaJson()`. */ - private _toolInputSchemaJson: { [name: string]: Record } = {}; + private _toolInputSchemaJson: { [name: string]: Record } = Object.create(null); /** * The JSON-serialized `inputSchema` of a registered tool, or `undefined` diff --git a/packages/server/test/server/registerPrototypeNames.test.ts b/packages/server/test/server/registerPrototypeNames.test.ts new file mode 100644 index 0000000000..8cc23483f0 --- /dev/null +++ b/packages/server/test/server/registerPrototypeNames.test.ts @@ -0,0 +1,92 @@ +import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it, vi } from 'vitest'; + +import { McpServer, ResourceTemplate } from '../../src/index.js'; + +/** + * Names that are inherited as own properties of Object.prototype. Indexing a + * plain `{}` registry by these names finds the inherited member, so + * registerTool/Prompt/Resource treat an empty server as already registered. + */ +const PROTOTYPE_NAMES = ['constructor', 'toString', '__proto__', 'hasOwnProperty', 'valueOf'] as const; + +describe('McpServer registries ignore Object.prototype names', () => { + it.each(PROTOTYPE_NAMES)('registerTool accepts %s on an empty server', name => { + const server = new McpServer({ name: 'r', version: '0' }); + expect(() => + server.registerTool(name, { description: 'x' }, async () => ({ content: [{ type: 'text', text: name }] })) + ).not.toThrow(); + }); + + it.each(PROTOTYPE_NAMES)('registerPrompt accepts %s on an empty server', name => { + const server = new McpServer({ name: 'r', version: '0' }); + expect(() => + server.registerPrompt(name, { description: 'x' }, async () => ({ + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: name } }] + })) + ).not.toThrow(); + }); + + it.each(PROTOTYPE_NAMES)('registerResource template accepts %s on an empty server', name => { + const server = new McpServer({ name: 'r', version: '0' }); + const template = new ResourceTemplate('proto://{id}', { list: undefined }); + expect(() => + server.registerResource(name, template, { description: 'x' }, async uri => ({ + contents: [{ uri: uri.href, text: name }] + })) + ).not.toThrow(); + }); + + it('lists and calls a tool whose name is inherited from Object.prototype', async () => { + const server = new McpServer({ name: 'r', version: '0' }); + server.registerTool('constructor', { description: 'x' }, async () => ({ + content: [{ type: 'text', text: 'from-constructor' }] + })); + + const [client, srv] = InMemoryTransport.createLinkedPair(); + await server.connect(srv); + await client.start(); + + const responses: JSONRPCMessage[] = []; + client.onmessage = m => responses.push(m); + + await client.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'c', version: '1.0.0' } + } + } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', id: 2, method: 'tools/list' } as JSONRPCMessage); + await client.send({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'constructor', arguments: {} } + } as JSONRPCMessage); + + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 3)).toBe(true)); + const listed = responses.find(r => 'id' in r && r.id === 2) as { result?: { tools: Array<{ name: string }> } }; + expect(listed.result?.tools.map(t => t.name)).toContain('constructor'); + + const called = responses.find(r => 'id' in r && r.id === 3) as { + result?: { content: Array<{ type: string; text?: string }> }; + error?: unknown; + }; + expect(called.error).toBeUndefined(); + expect(called.result?.content?.[0]?.text).toBe('from-constructor'); + + await server.close(); + }); + + it('still rejects a true duplicate registration', () => { + const server = new McpServer({ name: 'r', version: '0' }); + server.registerTool('constructor', { description: 'x' }, async () => ({ content: [] })); + expect(() => server.registerTool('constructor', { description: 'y' }, async () => ({ content: [] }))).toThrow(/already registered/); + }); +}); From af73ccabe2ebe226b34af5e3e262f6cad63cde38 Mon Sep 17 00:00:00 2001 From: DDT <141296842+Hugo-DDT@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:22:01 +0800 Subject: [PATCH 2/2] test(server): assert unregistered prototype tool names return not found Calling tools/call for an unregistered name like toString used to answer "disabled" via Object.prototype lookup; cover the fixed "not found" path. --- .../server/registerPrototypeNames.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/server/test/server/registerPrototypeNames.test.ts b/packages/server/test/server/registerPrototypeNames.test.ts index 8cc23483f0..88b7ae6a1b 100644 --- a/packages/server/test/server/registerPrototypeNames.test.ts +++ b/packages/server/test/server/registerPrototypeNames.test.ts @@ -84,6 +84,52 @@ describe('McpServer registries ignore Object.prototype names', () => { await server.close(); }); + it('calling an unregistered prototype-named tool returns not found, not disabled', async () => { + // With a plain `{}` registry, `_registeredTools['toString']` resolves to + // Object.prototype.toString (truthy but without `.enabled`), so tools/call + // used to answer "Tool toString disabled". Null-prototype maps make it + // correctly answer "not found". + // Register an unrelated tool so tools/call handlers are installed (lazy). + const server = new McpServer({ name: 'r', version: '0' }); + server.registerTool('hello', { description: 'x' }, async () => ({ + content: [{ type: 'text', text: 'hi' }] + })); + + const [client, srv] = InMemoryTransport.createLinkedPair(); + await server.connect(srv); + await client.start(); + + const responses: JSONRPCMessage[] = []; + client.onmessage = m => responses.push(m); + + await client.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'c', version: '1.0.0' } + } + } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage); + await client.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'toString', arguments: {} } + } as JSONRPCMessage); + + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true)); + const called = responses.find(r => 'id' in r && r.id === 2) as { + error?: { message?: string }; + }; + expect(called.error?.message).toMatch(/Tool toString not found/); + expect(called.error?.message).not.toMatch(/disabled/); + + await server.close(); + }); + it('still rejects a true duplicate registration', () => { const server = new McpServer({ name: 'r', version: '0' }); server.registerTool('constructor', { description: 'x' }, async () => ({ content: [] }));