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/); + }); +});