Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/null-prototype-mcp-registries.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 7 additions & 5 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> } = {};
private _toolInputSchemaJson: { [name: string]: Record<string, unknown> } = Object.create(null);

/**
* The JSON-serialized `inputSchema` of a registered tool, or `undefined`
Expand Down
92 changes: 92 additions & 0 deletions packages/server/test/server/registerPrototypeNames.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading