From c83d49b2d2ddfc49bdef65884340784358fd9072 Mon Sep 17 00:00:00 2001 From: Martin Garramon Date: Tue, 24 Mar 2026 22:26:51 -0300 Subject: [PATCH 1/2] feat: add type coercion for tool arguments before validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLM models frequently send string values for non-string tool parameters (e.g. "42" instead of 42, "true" instead of true). This adds a coerceToolArgs() utility that applies safe, conservative type coercions based on the JSON Schema before schema validation runs. Coercion rules follow the AJV coercion table: - string → number/integer: Number() only if finite and non-empty - string → boolean: exact "true"/"false" only - number/boolean → string: String() - Nested objects: recursive coercion Fixes #1361 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/util/standardSchema.ts | 71 ++++++++++ packages/server/src/server/mcp.ts | 4 +- .../test1361.tool-arg-type-coercion.test.ts | 125 ++++++++++++++++++ 3 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 test/integration/test/issues/test1361.tool-arg-type-coercion.test.ts diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts index d95572c0e..f6b4fb516 100644 --- a/packages/core/src/util/standardSchema.ts +++ b/packages/core/src/util/standardSchema.ts @@ -142,6 +142,77 @@ export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'in return schema['~standard'].jsonSchema[io]({ target: 'draft-2020-12' }); } +// Type coercion for tool arguments +// Models frequently send string values for non-string parameters (e.g. "42" instead of 42). +// This applies safe, conservative coercions following the AJV coercion table before schema validation. + +type JsonSchemaProperty = { + type?: string; + properties?: Record; +}; + +function coerceValue(value: unknown, targetType: string): unknown { + if (value === null || value === undefined) return value; + + const sourceType = typeof value; + if (sourceType === targetType) return value; + + switch (targetType) { + case 'number': + case 'integer': { + if (sourceType === 'string') { + const n = Number(value); + if (Number.isFinite(n) && (value as string).trim() !== '') { + if (targetType === 'integer') return Math.trunc(n); + return n; + } + } + return value; + } + case 'boolean': { + if (value === 'true') return true; + if (value === 'false') return false; + return value; + } + case 'string': { + if (sourceType === 'number' || sourceType === 'boolean') { + return String(value); + } + return value; + } + default: { + return value; + } + } +} + +function coerceObject(args: Record, properties: Record): Record { + const result: Record = { ...args }; + for (const [key, schema] of Object.entries(properties)) { + if (!(key in result)) continue; + const value = result[key]; + if (schema.type && schema.type !== 'object' && schema.type !== 'array') { + result[key] = coerceValue(value, schema.type); + } else if (schema.type === 'object' && schema.properties && typeof value === 'object' && value !== null && !Array.isArray(value)) { + result[key] = coerceObject(value as Record, schema.properties); + } + } + return result; +} + +/** + * Coerces tool argument types based on the JSON Schema derived from the tool's input schema. + * Applies safe, conservative coercions before schema validation runs. + * + * @see https://github.com/modelcontextprotocol/typescript-sdk/issues/1361 + */ +export function coerceToolArgs(schema: StandardJSONSchemaV1, args: Record): Record { + const jsonSchema = standardSchemaToJsonSchema(schema, 'input'); + const properties = jsonSchema.properties as Record | undefined; + if (!properties) return args; + return coerceObject(args, properties); +} + // Validation export type StandardSchemaValidationResult = { success: true; data: T } | { success: false; error: string }; diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 4d9f81c50..be0c1f881 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -30,6 +30,7 @@ import type { import { assertCompleteRequestPrompt, assertCompleteRequestResourceTemplate, + coerceToolArgs, promptArgumentsFromStandardSchema, ProtocolError, ProtocolErrorCode, @@ -249,7 +250,8 @@ export class McpServer { return undefined as Args; } - const parseResult = await validateStandardSchema(tool.inputSchema, args ?? {}); + const coercedArgs = coerceToolArgs(tool.inputSchema, (args ?? {}) as Record); + const parseResult = await validateStandardSchema(tool.inputSchema, coercedArgs); if (!parseResult.success) { throw new ProtocolError( ProtocolErrorCode.InvalidParams, diff --git a/test/integration/test/issues/test1361.tool-arg-type-coercion.test.ts b/test/integration/test/issues/test1361.tool-arg-type-coercion.test.ts new file mode 100644 index 000000000..66cb8886b --- /dev/null +++ b/test/integration/test/issues/test1361.tool-arg-type-coercion.test.ts @@ -0,0 +1,125 @@ +/** + * Regression test for https://github.com/modelcontextprotocol/typescript-sdk/issues/1361 + * + * LLM models frequently send string values for non-string tool parameters + * (e.g. "42" instead of 42, "true" instead of true). The SDK should coerce + * these to the expected types before schema validation. + */ + +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport } from '@modelcontextprotocol/core'; +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +async function setupAndCall( + schema: Parameters[1] & { inputSchema: unknown }, + args: Record +) { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }); + const client = new Client({ name: 'test client', version: '1.0' }); + + let receivedArgs: unknown; + mcpServer.registerTool('test-tool', schema, async toolArgs => { + receivedArgs = toolArgs; + return { content: [{ type: 'text', text: JSON.stringify(toolArgs) }] }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'tools/call', + params: { name: 'test-tool', arguments: args } + }); + + return { result, receivedArgs }; +} + +describe('Issue #1361: Tool argument type coercion', () => { + test('coerces string to number', async () => { + const { receivedArgs } = await setupAndCall({ inputSchema: z.object({ count: z.number() }) }, { count: '42' }); + expect(receivedArgs).toEqual({ count: 42 }); + }); + + test('coerces string to integer (truncates decimals)', async () => { + const { receivedArgs } = await setupAndCall({ inputSchema: z.object({ page: z.int() }) }, { page: '3.9' }); + expect(receivedArgs).toEqual({ page: 3 }); + }); + + test('coerces string "true"/"false" to boolean', async () => { + const { receivedArgs } = await setupAndCall({ inputSchema: z.object({ verbose: z.boolean() }) }, { verbose: 'true' }); + expect(receivedArgs).toEqual({ verbose: true }); + }); + + test('coerces string "false" to boolean false', async () => { + const { receivedArgs } = await setupAndCall({ inputSchema: z.object({ verbose: z.boolean() }) }, { verbose: 'false' }); + expect(receivedArgs).toEqual({ verbose: false }); + }); + + test('coerces number to string', async () => { + const { receivedArgs } = await setupAndCall({ inputSchema: z.object({ label: z.string() }) }, { label: 42 }); + expect(receivedArgs).toEqual({ label: '42' }); + }); + + test('coerces boolean to string', async () => { + const { receivedArgs } = await setupAndCall({ inputSchema: z.object({ flag: z.string() }) }, { flag: true }); + expect(receivedArgs).toEqual({ flag: 'true' }); + }); + + test('does not coerce non-numeric strings to number', async () => { + const { result } = await setupAndCall({ inputSchema: z.object({ count: z.number() }) }, { count: 'not-a-number' }); + expect(result.isError).toBe(true); + }); + + test('does not coerce truthy strings other than "true"/"false" to boolean', async () => { + const { result } = await setupAndCall({ inputSchema: z.object({ verbose: z.boolean() }) }, { verbose: 'yes' }); + expect(result.isError).toBe(true); + }); + + test('coerces nested object properties', async () => { + const { receivedArgs } = await setupAndCall( + { + inputSchema: z.object({ + config: z.object({ + timeout: z.number(), + debug: z.boolean() + }) + }) + }, + { config: { timeout: '30', debug: 'true' } } + ); + expect(receivedArgs).toEqual({ config: { timeout: 30, debug: true } }); + }); + + test('passes through correctly-typed values unchanged', async () => { + const { receivedArgs } = await setupAndCall( + { + inputSchema: z.object({ + count: z.number(), + name: z.string(), + verbose: z.boolean() + }) + }, + { count: 42, name: 'test', verbose: true } + ); + expect(receivedArgs).toEqual({ count: 42, name: 'test', verbose: true }); + }); + + test('handles optional parameters with coercion', async () => { + const { receivedArgs } = await setupAndCall( + { + inputSchema: z.object({ + limit: z.number().optional(), + offset: z.number().optional() + }) + }, + { limit: '10' } + ); + expect(receivedArgs).toEqual({ limit: 10 }); + }); + + test('does not coerce empty string to number', async () => { + const { result } = await setupAndCall({ inputSchema: z.object({ count: z.number() }) }, { count: '' }); + expect(result.isError).toBe(true); + }); +}); From ea60e9b7219ae7e8f9cd0aa4406d2087638c3862 Mon Sep 17 00:00:00 2001 From: Martin Garramon Date: Wed, 25 Mar 2026 10:39:34 -0300 Subject: [PATCH 2/2] chore: add changeset for tool argument type coercion Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/tool-arg-type-coercion.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/tool-arg-type-coercion.md diff --git a/.changeset/tool-arg-type-coercion.md b/.changeset/tool-arg-type-coercion.md new file mode 100644 index 000000000..e31534c7d --- /dev/null +++ b/.changeset/tool-arg-type-coercion.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/server': patch +--- + +Add type coercion for tool arguments so string values from LLMs are automatically converted to match the expected JSON Schema type (number, boolean, integer, array, object) before validation. Fixes cases where models send `"42"` instead of `42`.