From dd30af18669efa2d0fef62fc00f4d1fc21d2f638 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:31:12 +0000 Subject: [PATCH 1/2] test(e2e): cover tools/call without arguments Add two requirement ids and bodies: a tool whose inputSchema fields are all optional, called with no `arguments` key, runs with {} and returns its result; a tool with a required argument, called with no `arguments` key, is refused as a tool execution error (isError true) naming the argument. Existing ids are unchanged. Exercises the `args ?? {}` fallback in the McpServer tools/call path on every transport and both entry arms. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012VRbFCp41otcScXE1YY3es --- test/e2e/requirements.ts | 10 +++++ test/e2e/scenarios/tools.test.ts | 64 ++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index e686b04084..b4641c78f9 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -379,6 +379,16 @@ export const REQUIREMENTS: Record = { behavior: "Log notifications emitted by a tool handler during execution reach the client's logging callback before the tool result returns." }, + 'tools:call:omitted-args:all-optional': { + source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#calling-tools', + behavior: + 'tools/call that omits the arguments field entirely for a tool whose input schema fields are all optional runs the handler with an empty object and returns its result.' + }, + 'tools:call:omitted-args:required': { + source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#error-handling', + behavior: + 'tools/call that omits the arguments field entirely for a tool with a required argument returns a tool execution error (isError true, content naming that argument) without invoking the handler.' + }, 'tools:call:progress': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress#progress-flow', behavior: "Progress notifications emitted by a tool handler reach the caller's progress callback before the tool result returns.", diff --git a/test/e2e/scenarios/tools.test.ts b/test/e2e/scenarios/tools.test.ts index 741665143f..40401698e0 100644 --- a/test/e2e/scenarios/tools.test.ts +++ b/test/e2e/scenarios/tools.test.ts @@ -517,6 +517,70 @@ verifies('tools:call:is-error', async ({ transport }: TestArgs) => { client.transport!.onmessage = original; }); +verifies('tools:call:omitted-args:all-optional', async ({ transport }: TestArgs) => { + // Shared across factory calls so stateless still observes what the handler received. + const received: unknown[] = []; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool( + 'greet', + { description: 'Greets by name, or generically.', inputSchema: z.object({ name: z.string().optional() }) }, + args => { + received.push(args); + return { content: [{ type: 'text', text: args.name ? `Hello, ${args.name}!` : 'Hello!' }] }; + } + ); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + // Tap outbound wire messages to prove the request carries no `arguments` key at all (unlike `arguments: {}`). + const outbound: JSONRPCMessage[] = []; + const tx = client.transport!; + const originalSend = tx.send.bind(tx); + tx.send = async (m, opts) => { + outbound.push(m); + return originalSend(m, opts); + }; + + const result = await client.callTool({ name: 'greet' }); + + const call = outbound.find(m => 'method' in m && m.method === 'tools/call') as { params: Record } | undefined; + expect(call).toBeDefined(); + expect(call!.params).not.toHaveProperty('arguments'); + + expect(result.isError).toBeFalsy(); + expect(result.content).toEqual([{ type: 'text', text: 'Hello!' }]); + // The omitted field is validated as an empty object, so the handler sees no keys rather than undefined. + expect(received).toEqual([{}]); + + tx.send = originalSend; +}); + +verifies('tools:call:omitted-args:required', async ({ transport }: TestArgs) => { + // Shared across factory calls so stateless still observes the count. + const handlerCalls = { n: 0 }; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('summarize', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => { + handlerCalls.n++; + return { content: [{ type: 'text', text: `Summary of: ${text}` }] }; + }); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + // No `arguments` key at all: an input validation failure is a tool execution error, not a JSON-RPC error. + const result = await client.callTool({ name: 'summarize' }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([{ type: 'text', text: expect.stringMatching(/input validation error/i) }]); + expect(result.content).toEqual([{ type: 'text', text: expect.stringContaining('text') }]); + expect(handlerCalls.n).toBe(0); +}); + verifies( 'tools:call:unknown-name', async ({ transport }: TestArgs) => { From 6c037e9a348bbccdfeff80ec9fe48f0f5b42d346 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:40:34 +0000 Subject: [PATCH 2/2] test(e2e): state the omitted-arguments tool case as a tool execution error Also narrow the captured request with isJSONRPCRequest instead of a cast. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012VRbFCp41otcScXE1YY3es --- test/e2e/requirements.ts | 2 +- test/e2e/scenarios/tools.test.ts | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index b4641c78f9..b3dc866afd 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -387,7 +387,7 @@ export const REQUIREMENTS: Record = { 'tools:call:omitted-args:required': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#error-handling', behavior: - 'tools/call that omits the arguments field entirely for a tool with a required argument returns a tool execution error (isError true, content naming that argument) without invoking the handler.' + 'tools/call that omits the arguments field entirely for a tool with a required argument is answered as a tool execution error (isError: true) whose content names the missing argument, without invoking the handler.' }, 'tools:call:progress': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress#progress-flow', diff --git a/test/e2e/scenarios/tools.test.ts b/test/e2e/scenarios/tools.test.ts index 40401698e0..b21c997820 100644 --- a/test/e2e/scenarios/tools.test.ts +++ b/test/e2e/scenarios/tools.test.ts @@ -31,7 +31,14 @@ import type { RequestId, Tool } from '@modelcontextprotocol/server'; -import { McpServer, ProtocolError, ProtocolErrorCode, Server, UrlElicitationRequiredError } from '@modelcontextprotocol/server'; +import { + isJSONRPCRequest, + McpServer, + ProtocolError, + ProtocolErrorCode, + Server, + UrlElicitationRequiredError +} from '@modelcontextprotocol/server'; import { AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv'; import { expect, vi } from 'vitest'; import { z } from 'zod/v4'; @@ -546,9 +553,10 @@ verifies('tools:call:omitted-args:all-optional', async ({ transport }: TestArgs) const result = await client.callTool({ name: 'greet' }); - const call = outbound.find(m => 'method' in m && m.method === 'tools/call') as { params: Record } | undefined; + const call = outbound.find(m => isJSONRPCRequest(m) && m.method === 'tools/call'); expect(call).toBeDefined(); - expect(call!.params).not.toHaveProperty('arguments'); + if (!call || !isJSONRPCRequest(call)) throw new Error('expected tools/call request'); + expect(call.params).not.toHaveProperty('arguments'); expect(result.isError).toBeFalsy(); expect(result.content).toEqual([{ type: 'text', text: 'Hello!' }]);