From dc24b03e30a94998412df267cedf1e48da01ac28 Mon Sep 17 00:00:00 2001 From: Ruiming Zhao Date: Mon, 10 Aug 2026 00:09:36 -0700 Subject: [PATCH] fix(server): validate low-level tool inputs --- .changeset/validate-low-level-tool-inputs.md | 5 + docs/advanced/low-level-server.md | 51 ++--- docs/advanced/schema-libraries.md | 8 +- .../advanced/low-level-server.examples.ts | 48 +---- packages/server/src/server/mcp.ts | 3 +- packages/server/src/server/server.ts | 103 +++++++++- .../server/test/server/mcp.compat.test.ts | 4 +- packages/server/test/server/server.test.ts | 192 +++++++++++++++++- 8 files changed, 321 insertions(+), 93 deletions(-) create mode 100644 .changeset/validate-low-level-tool-inputs.md diff --git a/.changeset/validate-low-level-tool-inputs.md b/.changeset/validate-low-level-tool-inputs.md new file mode 100644 index 0000000000..c29bba6e70 --- /dev/null +++ b/.changeset/validate-low-level-tool-inputs.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Validate low-level `Server` tool calls against the `inputSchema` advertised by `tools/list` before dispatching them. Invalid arguments now return an `isError` tool result instead of reaching the handler. diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 106abe05d3..8d4ef30cda 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -4,7 +4,7 @@ shape: explanation # Low-level Server -`Server` is the **protocol layer** under `McpServer`: it routes each JSON-RPC request to the handler you register for that method string, and nothing more. Rebuild the `search` tool from [Tools](../servers/tools.md) on it to see what `registerTool` adds. +`Server` is the **protocol layer** under `McpServer`: it routes each JSON-RPC request to the handler you register for that method string, and validates low-level tool calls against the schemas it advertises. Rebuild the `search` tool from [Tools](../servers/tools.md) on it to see what `registerTool` adds. ## Build the server and list your tools by hand @@ -38,6 +38,8 @@ server.setRequestHandler('tools/list', async () => ({ A client's `tools/list` returns exactly the array you wrote — the SDK derived none of it. +When the server answers `tools/list`, it also remembers each tool's `inputSchema` for this connection. A later `tools/call` with arguments that do not match the advertised schema returns an `isError: true` tool result before your handler runs. + ::: tip Drop `capabilities: { tools: {} }` and `setRequestHandler('tools/list', …)` throws. `Server` never infers a capability from a handler, the way `registerTool` registers the `tools` capability for you. ::: @@ -63,50 +65,21 @@ An in-memory `Client` connected to this server — [Test a server](../testing.md [ { type: 'text', text: 'Travel mug\nMug rack' } ] ``` -Now call it with `{ query: 42 }`. The protocol layer checks only that `arguments` is an object, so the value reaches the handler and the handler crashes: - -``` -ProtocolError -32603: query.toLowerCase is not a function -``` - -`callTool` rejected with a protocol error instead of resolving to an `isError: true` tool result — [Errors](../servers/errors.md) covers the difference. - -## Validate arguments yourself - -From one Zod `inputSchema` the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and infers the handler's argument types. Here you wrote the JSON Schema by hand, the cast went unchecked, and nothing tied the two together. - -`fromJsonSchema` — exported from `@modelcontextprotocol/server` — wraps a JSON Schema object as a validator you run yourself. Registering `tools/call` again replaces the handler; this one rejects before it touches the arguments. - -```ts source="../../examples/guides/advanced/low-level-server.examples.ts#lowLevel_validate" -const SearchArguments = fromJsonSchema<{ query: string }>({ - type: 'object', - properties: { query: { type: 'string' } }, - required: ['query'] -}); - -server.setRequestHandler('tools/call', async request => { - if (request.params.name !== 'search') { - return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true }; - } - const parsed = await SearchArguments['~standard'].validate(request.params.arguments ?? {}); - if (parsed.issues) { - return { content: [{ type: 'text', text: parsed.issues.map(issue => issue.message).join('; ') }], isError: true }; - } - const hits = catalog.filter(product => product.name.toLowerCase().includes(parsed.value.query.toLowerCase())); - return { content: [{ type: 'text', text: hits.map(product => product.name).join('\n') }] }; -}); -``` - -The same `{ query: 42 }` call now comes back as an ordinary tool result the model can read and retry: +Now call it with `{ query: 42 }`. The protocol layer rejects it against the schema before the value reaches the handler: ``` { - content: [ { type: 'text', text: 'data/query must be string' } ], + content: [ + { + type: 'text', + text: 'Input validation error: Invalid arguments for tool search: data/query must be string' + } + ], isError: true } ``` -Keeping the schema you advertise in `tools/list` identical to the one you validate with is still on you — `registerTool` derives both from the same object. +The handler is not called, and the model can read the tool result and retry with valid arguments. ## Serve it with the same entry points @@ -155,7 +128,7 @@ You never choose once for the whole program. Start on `McpServer` and take over ## Recap - `Server` is the protocol layer: `setRequestHandler(method, handler)` per spec method, and nothing derived on top. -- On `Server` you write the JSON Schema in `tools/list` and the argument validation in `tools/call`; `registerTool` derives both from one Zod schema. +- On `Server` you write the JSON Schema in `tools/list`, and the SDK validates later calls against it before dispatch; `registerTool` derives the schema and parses the handler arguments from one Standard Schema. - A handler exception on `Server` reaches the client as a protocol error, not as an `isError: true` tool result. - `serveStdio` and `createMcpHandler` accept a factory that returns a `Server` unchanged. - `mcp.server` is the per-method escape hatch; default to `McpServer` and drop to `Server` only where you own dispatch. diff --git a/docs/advanced/schema-libraries.md b/docs/advanced/schema-libraries.md index 18bfd7b3bc..7baf3d0786 100644 --- a/docs/advanced/schema-libraries.md +++ b/docs/advanced/schema-libraries.md @@ -125,7 +125,7 @@ The SDK validates `structuredContent` against the ArkType schema before the resu ## Swap the JSON Schema validator -The server runs a JSON Schema validator in two places: a `fromJsonSchema` schema, and [elicitation](../servers/elicitation.md) form responses. Build one from the `validators/ajv` subpath, which re-exports the SDK's bundled `Ajv` and `addFormats`. +The server runs a JSON Schema validator for low-level `Server` tool calls after a `tools/list` response, for [elicitation](../servers/elicitation.md) form responses, and for `fromJsonSchema` schemas that use the supplied validator. Build one from the `validators/ajv` subpath, which re-exports the SDK's bundled `Ajv` and `addFormats`. ```ts source="../../examples/guides/advanced/schema-libraries.examples.ts#jsonSchemaValidator_ajv" import { addFormats, Ajv, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv'; @@ -137,10 +137,10 @@ const validator = new AjvJsonSchemaValidator(ajv); const strict = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchemaValidator: validator }); ``` -`strict` now checks elicitation form responses with your `Ajv` instance. +`strict` now checks elicitation form responses with your `Ajv` instance. The high-level `McpServer` parses tool arguments through the Standard Schema supplied to `registerTool`; the `jsonSchemaValidator` option is used for tool calls when you use the low-level `Server` API. ::: warning -`jsonSchemaValidator` covers elicitation form responses only. A `fromJsonSchema` schema binds its validator at creation — pass yours as the second argument: `fromJsonSchema(document, validator)`. +`jsonSchemaValidator` on a low-level `Server` covers the raw tool schemas it has advertised and elicitation form responses. A `fromJsonSchema` schema binds its validator at creation — pass yours as the second argument: `fromJsonSchema(document, validator)`. ::: ## Pick the validator for your runtime @@ -160,5 +160,5 @@ const edge = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchem - `inputSchema`, `outputSchema`, and a prompt's `argsSchema` accept any Standard Schema that exposes JSON Schema — Zod and ArkType as-is, Valibot through `@valibot/to-json-schema`. - The raw-shape overload (`inputSchema: { name: z.string() }`) is deprecated; pass a schema object. - `fromJsonSchema(document)` registers a JSON Schema you already have; the generic parameter types the handler's arguments. -- `jsonSchemaValidator` on the server options swaps the validator for elicitation form responses; `fromJsonSchema` takes its own as a second argument. +- `jsonSchemaValidator` on low-level `Server` options swaps the validator for raw tool schemas and elicitation form responses; `McpServer` parses registered tools through their Standard Schemas; `fromJsonSchema` takes its own validator as a second argument. - The default validator is runtime-selected — AJV on Node.js, `@cfworker/json-schema` on workerd and browsers — and the `validators/ajv` and `validators/cf-worker` subpaths force either one. diff --git a/examples/guides/advanced/low-level-server.examples.ts b/examples/guides/advanced/low-level-server.examples.ts index ba9b8e965c..1b9a6d4da9 100644 --- a/examples/guides/advanced/low-level-server.examples.ts +++ b/examples/guides/advanced/low-level-server.examples.ts @@ -17,7 +17,7 @@ /* eslint-disable no-console, import/no-duplicates */ // Harness imports. The page's lead block (the first region) carries its own // `Server` import so the rendered fence stands alone. -import { createMcpHandler, fromJsonSchema, McpServer } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; @@ -72,55 +72,25 @@ server.setRequestHandler('tools/call', async request => { // page's lead region stays self-contained. // --------------------------------------------------------------------------- -const { Client, InMemoryTransport, ProtocolError } = await import('@modelcontextprotocol/client'); +const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); const client = new Client({ name: 'low-level-docs-harness', version: '1.0.0' }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); await client.connect(clientTransport); +await client.listTools(); // The handler answers a valid call exactly like the McpServer version. const result = await client.callTool({ name: 'search', arguments: { query: 'mug' } }); console.log(result.content); -// Nothing validated `query`, so a wrongly-typed argument reaches the handler -// and crashes it: the client sees a JSON-RPC error, not a tool result. -const crashed = await client.callTool({ name: 'search', arguments: { query: 42 } }).catch((error: unknown) => error); -if (!(crashed instanceof ProtocolError)) { - throw new Error(`low-level-server.md expected the unvalidated call to reject: ${JSON.stringify(crashed)}`); -} -console.log(`${crashed.name} ${crashed.code}: ${crashed.message}`); - -// --------------------------------------------------------------------------- -// "Validate arguments yourself" -// --------------------------------------------------------------------------- - -//#region lowLevel_validate -const SearchArguments = fromJsonSchema<{ query: string }>({ - type: 'object', - properties: { query: { type: 'string' } }, - required: ['query'] -}); - -server.setRequestHandler('tools/call', async request => { - if (request.params.name !== 'search') { - return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true }; - } - const parsed = await SearchArguments['~standard'].validate(request.params.arguments ?? {}); - if (parsed.issues) { - return { content: [{ type: 'text', text: parsed.issues.map(issue => issue.message).join('; ') }], isError: true }; - } - const hits = catalog.filter(product => product.name.toLowerCase().includes(parsed.value.query.toLowerCase())); - return { content: [{ type: 'text', text: hits.map(product => product.name).join('\n') }] }; -}); -//#endregion lowLevel_validate - -// The same wrongly-typed call now comes back as an ordinary isError result. -const rejected = await client.callTool({ name: 'search', arguments: { query: 42 } }); -console.log(rejected); -if (rejected.isError !== true) { - throw new Error(`low-level-server.md expected the validated call to return isError: ${JSON.stringify(rejected)}`); +// The low-level Server now validates a declared inputSchema before dispatch, +// so a wrongly-typed argument returns a tool error without invoking the handler. +const rejectedBySchema = await client.callTool({ name: 'search', arguments: { query: 42 } }); +if (rejectedBySchema.isError !== true) { + throw new Error(`low-level-server.md expected schema validation to return isError: ${JSON.stringify(rejectedBySchema)}`); } +console.log(rejectedBySchema); await client.close(); await server.close(); diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index d2e40181e4..963344f026 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -48,7 +48,7 @@ import type * as z from 'zod/v4'; import { getCompleter, isCompletable } from './completable'; import type { ServerOptions } from './server'; -import { Server } from './server'; +import { disableLowLevelToolInputValidation, Server } from './server'; /** * High-level MCP server that provides a simpler API for working with resources, tools, and prompts. @@ -116,6 +116,7 @@ export class McpServer { constructor(serverInfo: Implementation, options?: ServerOptions) { this.server = new Server(serverInfo, options); + disableLowLevelToolInputValidation(this.server); // 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 diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 5de0d8919c..b5300e2a58 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -19,9 +19,11 @@ import type { InitializeResult, JSONRPCRequest, JsonSchemaType, + JsonSchemaValidator, jsonSchemaValidator, ListRootsRequest, ListRootsResult, + ListToolsResult, LoggingLevel, LoggingMessageNotification, MessageExtraInfo, @@ -91,10 +93,11 @@ export type ServerOptions = ProtocolOptions & { instructions?: string; /** - * JSON Schema validator for elicitation response validation. + * JSON Schema validator for tool input and elicitation response validation. * - * The validator is used to validate user input returned from elicitation - * requests against the requested schema. + * The validator is used to validate low-level tool calls against the + * schema advertised by `tools/list`, and to validate user input returned + * from elicitation requests against the requested schema. * * @default Runtime-selected validator (AJV-backed on Node.js, `@cfworker/json-schema`-backed on browser/workerd runtimes) */ @@ -207,6 +210,7 @@ export type ServerOptions = ProtocolOptions & { let writeClientIdentity: (server: Server, identity: PerRequestClientIdentity) => void; let installDiscoverHandler: (server: Server, servedModernVersions: readonly string[]) => void; let readServerIdentity: (server: Server) => Implementation; +const highLevelServers = new WeakSet(); /** Connection-scoped client-identity fields backfilled per request from a validated `_meta` envelope. */ export interface PerRequestClientIdentity { @@ -251,6 +255,17 @@ export function serverIdentityOf(server: Server): Implementation { return readServerIdentity(server); } +/** + * Package-internal: the high-level {@linkcode server/mcp.McpServer | McpServer} validates and parses + * tool arguments through Standard Schema, which may intentionally differ from + * the JSON Schema it advertises (for example, a coercing schema). Mark its + * underlying Server so the low-level JSON Schema validation seam does not run + * a second, narrower validation pass. + */ +export function disableLowLevelToolInputValidation(server: Server): void { + highLevelServers.add(server); +} + /** * An MCP server on top of a pluggable transport. * @@ -285,6 +300,7 @@ export class Server extends Protocol { private _capabilities: ServerCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; + private _toolInputValidators: Map> | undefined; private _cacheHints?: ServerOptions['cacheHints']; private _requestStateVerify?: (state: string, ctx: ServerContext) => unknown | Promise; private _inputRequiredServing: { maxRounds: number; roundTimeoutMs: number; legacyShim: boolean }; @@ -473,6 +489,18 @@ export class Server extends Protocol { method: string, handler: (request: JSONRPCRequest, ctx: ServerContext) => Promise ): (request: JSONRPCRequest, ctx: ServerContext) => Promise { + const handlerWithToolSchemaCapture = + method === 'tools/list' && !highLevelServers.has(this) + ? async (request: JSONRPCRequest, ctx: ServerContext): Promise => { + const result = await handler(request, ctx); + const validatedResult = codecForVersion(this._negotiatedProtocolVersion).validateResult('tools/list', result); + if (validatedResult.ok) { + this._rememberToolInputSchemas(request, validatedResult.value as ListToolsResult); + } + return result; + } + : handler; + if (method !== 'tools/call') { const cacheHint = (this._cacheHints as Record | undefined)?.[method]; const isInputRequiredCapable = INPUT_REQUIRED_CAPABLE_METHODS.has(method); @@ -481,7 +509,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 handlerWithToolSchemaCapture(request, ctx); if (isInputRequiredResult(result)) { throw new ProtocolError( ProtocolErrorCode.InternalError, @@ -494,8 +522,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, handlerWithToolSchemaCapture, request, ctx) + : await handlerWithToolSchemaCapture(request, ctx); if (isInputRequiredResult(result)) { if (!isInputRequiredCapable) { throw new ProtocolError( @@ -530,7 +558,35 @@ export class Server extends Protocol { ); } - const result = await this._invokeInputRequiredCapableHandler('tools/call', handler, request, ctx); + const validatedCall = validatedRequest.value; + const toolName = validatedCall.params.name; + const handlerWithToolInputValidation = async (callRequest: JSONRPCRequest, callCtx: ServerContext): Promise => { + const validator = this._toolInputValidators?.get(toolName); + if (validator !== undefined) { + try { + const validationResult = validator(validatedCall.params.arguments ?? {}); + if (!validationResult.valid) { + return { + content: [ + { + type: 'text', + text: `Input validation error: Invalid arguments for tool ${toolName}: ${validationResult.errorMessage}` + } + ], + isError: true + }; + } + } catch { + // A malformed or unsupported schema should not change the + // low-level handler's existing behavior. Wire validation + // still rejects an invalid tools/list response. + } + } + + return await handlerWithToolSchemaCapture(callRequest, callCtx); + }; + + const result = await this._invokeInputRequiredCapableHandler('tools/call', handlerWithToolInputValidation, request, ctx); if (isInputRequiredResult(result)) { // Already checked by the seam; the CallToolResult schema does // not apply to it (no widening — InputRequiredResult travels @@ -557,6 +613,39 @@ export class Server extends Protocol { }; } + private _rememberToolInputSchemas(request: JSONRPCRequest, result: ListToolsResult): void { + if (!Array.isArray(result.tools)) { + return; + } + + const cursor = request.params && typeof request.params === 'object' ? (request.params as { cursor?: unknown }).cursor : undefined; + if (cursor === undefined) { + this._toolInputValidators = undefined; + } + + for (const tool of result.tools) { + if (typeof tool?.name !== 'string') { + continue; + } + + try { + (this._toolInputValidators ??= new Map()).set( + tool.name, + this._jsonSchemaValidator.getValidator(tool.inputSchema as JsonSchemaType) + ); + } catch { + // Let the normal tools/list response validation report an + // invalid schema without changing low-level dispatch behavior. + this._toolInputValidators?.delete(tool.name); + } + } + } + + protected override _onclose(): void { + this._toolInputValidators = undefined; + super._onclose(); + } + /** * Whether this instance is bound to a 2026-07-28-or-later protocol * revision. Era is instance state — a serving entry (`createMcpHandler`, diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index ae7a0438b5..2ea205d18a 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -81,7 +81,7 @@ describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () = const server = new McpServer({ name: 't', version: '1.0.0' }); let received: { x: number } | undefined; - server.registerTool('echo', { inputSchema: { x: z.number() } }, async args => { + server.registerTool('echo', { inputSchema: { x: z.coerce.number() } }, async args => { received = args; return { content: [{ type: 'text' as const, text: String(args.x) }] }; }); @@ -108,7 +108,7 @@ describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () = jsonrpc: '2.0', id: 2, method: 'tools/call', - params: { name: 'echo', arguments: { x: 7 } } + params: { name: 'echo', arguments: { x: '7' } } } as JSONRPCMessage); await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true)); diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 0a96a0bb66..bbf0deb6f8 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -1,4 +1,4 @@ -import type { CallToolResult, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core-internal'; +import type { CallToolResult, JSONRPCMessage, JSONRPCRequest, Tool } from '@modelcontextprotocol/core-internal'; import { InitializeResultSchema, InMemoryTransport, @@ -242,4 +242,194 @@ describe('Server', () => { expect(result.structuredContent).toEqual({ ok: true }); }); }); + + describe('low-level tools/call input validation', () => { + async function callTool( + server: Server, + args: Record, + inputSchema: Tool['inputSchema'] = { + type: 'object', + properties: { code: { type: 'string' } }, + required: ['code'] + }, + requestList = true + ): Promise<{ response: JSONRPCMessage; receivedArgs: Record | undefined }> { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const waiters = new Map void>(); + const receivedArgs: { value: Record | undefined } = { value: undefined }; + + clientTransport.onmessage = message => { + if (!('id' in message) || message.id === undefined || message.id === null) { + return; + } + waiters.get(message.id)?.(message); + waiters.delete(message.id); + }; + + server.setRequestHandler('tools/list', () => ({ + tools: [ + { + name: 'scan_code_imports', + inputSchema + } + ] + })); + server.setRequestHandler('tools/call', request => { + receivedArgs.value = request.params.arguments; + return { content: [{ type: 'text', text: 'CLEAN' }] }; + }); + + await server.connect(serverTransport); + await clientTransport.start(); + + const request = (message: JSONRPCRequest): Promise => + new Promise(resolve => { + waiters.set(message.id, resolve); + void clientTransport.send(message); + }); + + await request({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' } + } + }); + await clientTransport.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + if (requestList) { + await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); + } + const response = await request({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'scan_code_imports', arguments: args } + }); + + await server.close(); + return { response, receivedArgs: receivedArgs.value }; + } + + it('returns a tool error and does not dispatch invalid arguments', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { tools: {} } }); + + const { response, receivedArgs } = await callTool(server, { code: 12345 }); + + if (!isJSONRPCResultResponse(response)) { + throw new Error(`Expected a result response, got: ${JSON.stringify(response)}`); + } + + expect(receivedArgs).toBeUndefined(); + expect(response.result).toMatchObject({ + isError: true, + content: [{ type: 'text', text: expect.stringContaining('Invalid arguments for tool scan_code_imports') }] + }); + }); + + it('dispatches arguments that satisfy the declared schema', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { tools: {} } }); + + const { response, receivedArgs } = await callTool(server, { code: 'import pathlib' }); + + if (!isJSONRPCResultResponse(response)) { + throw new Error(`Expected a result response, got: ${JSON.stringify(response)}`); + } + + expect(receivedArgs).toEqual({ code: 'import pathlib' }); + expect(response.result).toEqual({ content: [{ type: 'text', text: 'CLEAN' }] }); + }); + + it('does not reuse tool schemas after the connection closes', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { tools: {} } }); + + const firstCall = await callTool(server, { code: 12345 }); + expect(firstCall.receivedArgs).toBeUndefined(); + + const secondCall = await callTool( + server, + { code: 12345 }, + { + type: 'object', + properties: { code: { type: 'number' } }, + required: ['code'] + }, + false + ); + + expect(secondCall.receivedArgs).toEqual({ code: 12345 }); + expect(secondCall.response).toMatchObject({ result: { content: [{ type: 'text', text: 'CLEAN' }] } }); + }); + + it('preserves low-level dispatch when no tool list has been requested', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { tools: {} } }); + let called = false; + server.setRequestHandler('tools/call', () => { + called = true; + return { content: [{ type: 'text', text: 'CLEAN' }] }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const responsePromise = new Promise(resolve => { + clientTransport.onmessage = resolve; + }); + await server.connect(serverTransport); + await clientTransport.start(); + await clientTransport.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' } + } + }); + await responsePromise; + + const callResponsePromise = new Promise(resolve => { + clientTransport.onmessage = resolve; + }); + await clientTransport.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'scan_code_imports', arguments: { code: 12345 } } + }); + const response = await callResponsePromise; + + await server.close(); + expect(called).toBe(true); + expect(response).toMatchObject({ result: { content: [{ type: 'text', text: 'CLEAN' }] } }); + }); + + it('uses the configured JSON Schema validator for low-level tool calls', async () => { + const validator = { + getValidator: vi.fn(() => (value: unknown) => ({ + valid: false as const, + data: undefined, + errorMessage: `Rejected ${JSON.stringify(value)}` + })) + }; + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { tools: {} }, jsonSchemaValidator: validator }); + + const { response, receivedArgs } = await callTool(server, { code: 'import pathlib' }); + + expect(receivedArgs).toBeUndefined(); + expect(validator.getValidator).toHaveBeenCalledTimes(1); + expect(response).toMatchObject({ + result: { + isError: true, + content: [ + { + type: 'text', + text: 'Input validation error: Invalid arguments for tool scan_code_imports: Rejected {"code":"import pathlib"}' + } + ] + } + }); + }); + }); });