diff --git a/.changeset/green-dryers-film.md b/.changeset/green-dryers-film.md new file mode 100644 index 0000000000..dbad2e856f --- /dev/null +++ b/.changeset/green-dryers-film.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server": patch +--- + +fix(server): reject requests before initialization diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 1582f8c6eb..70a6aaab92 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -285,6 +285,7 @@ export class Server extends Protocol { private _capabilities: ServerCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; + private _receivedInitialize = false; private _cacheHints?: ServerOptions['cacheHints']; private _requestStateVerify?: (state: string, ctx: ServerContext) => unknown | Promise; private _inputRequiredServing: { maxRounds: number; roundTimeoutMs: number; legacyShim: boolean }; @@ -473,6 +474,17 @@ export class Server extends Protocol { method: string, handler: (request: JSONRPCRequest, ctx: ServerContext) => Promise ): (request: JSONRPCRequest, ctx: ServerContext) => Promise { + const lifecycleHandler: (request: JSONRPCRequest, ctx: ServerContext) => Promise = async (request, ctx) => { + if (!ctx.http && ctx.sessionId === undefined && !this._receivedInitialize && method !== 'initialize' && method !== 'ping') { + throw new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Server not initialized'); + } + const result = await handler(request, ctx); + if (method === 'initialize') { + this._receivedInitialize = true; + } + return result; + }; + if (method !== 'tools/call') { const cacheHint = (this._cacheHints as Record | undefined)?.[method]; const isInputRequiredCapable = INPUT_REQUIRED_CAPABLE_METHODS.has(method); @@ -481,7 +493,7 @@ export class Server extends Protocol { // whose result vocabulary does not include it is never // mis-typed onto the wire. return async (request, ctx) => { - const result = await handler(request, ctx); + const result = await lifecycleHandler(request, ctx); if (isInputRequiredResult(result)) { throw new ProtocolError( ProtocolErrorCode.InternalError, @@ -494,8 +506,8 @@ export class Server extends Protocol { } return async (request, ctx) => { const result = isInputRequiredCapable - ? await this._invokeInputRequiredCapableHandler(method, handler, request, ctx) - : await handler(request, ctx); + ? await this._invokeInputRequiredCapableHandler(method, lifecycleHandler, request, ctx) + : await lifecycleHandler(request, ctx); if (isInputRequiredResult(result)) { if (!isInputRequiredCapable) { throw new ProtocolError( @@ -530,7 +542,7 @@ export class Server extends Protocol { ); } - const result = await this._invokeInputRequiredCapableHandler('tools/call', handler, request, ctx); + const result = await this._invokeInputRequiredCapableHandler('tools/call', lifecycleHandler, request, ctx); if (isInputRequiredResult(result)) { // Already checked by the seam; the CallToolResult schema does // not apply to it (no widening — InputRequiredResult travels diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 0a96a0bb66..437a86e49b 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -4,6 +4,7 @@ import { InMemoryTransport, isJSONRPCResultResponse, LATEST_PROTOCOL_VERSION, + ProtocolErrorCode, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core-internal'; import { Server } from '../../src/server/server'; @@ -84,6 +85,73 @@ describe('Server', () => { await server.close(); }); + + it('rejects requests before initialize', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { tools: {} } }); + + server.setRequestHandler('tools/list', async () => ({ tools: [] })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + + const responses: JSONRPCMessage[] = []; + clientTransport.onmessage = message => responses.push(message); + await clientTransport.start(); + + await clientTransport.send({ + jsonrpc: '2.0', + method: 'notifications/initialized' + } as JSONRPCMessage); + + await clientTransport.send({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {} + } as JSONRPCMessage); + + await vi.waitFor(() => expect(responses.some(message => 'id' in message && message.id === 1)).toBe(true)); + + const rejected = responses.find(message => 'id' in message && message.id === 1); + expect(rejected).toMatchObject({ + error: { + code: ProtocolErrorCode.InvalidRequest, + message: 'Server not initialized' + } + }); + + await clientTransport.send({ + jsonrpc: '2.0', + id: 2, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' } + } + } as JSONRPCMessage); + await vi.waitFor(() => expect(responses.some(message => 'id' in message && message.id === 2)).toBe(true)); + + await clientTransport.send({ + jsonrpc: '2.0', + method: 'notifications/initialized' + } as JSONRPCMessage); + + await clientTransport.send({ + jsonrpc: '2.0', + id: 3, + method: 'tools/list', + params: {} + } as JSONRPCMessage); + + await vi.waitFor(() => expect(responses.some(message => 'id' in message && message.id === 3)).toBe(true)); + + expect(responses.find(message => 'id' in message && message.id === 3)).toMatchObject({ + result: { tools: [] } + }); + + await server.close(); + }); }); describe('getNegotiatedProtocolVersion', () => {