Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/tool-arg-type-coercion.md
Original file line number Diff line number Diff line change
@@ -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`.
71 changes: 71 additions & 0 deletions packages/core/src/util/standardSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, JsonSchemaProperty>;
};

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<string, unknown>, properties: Record<string, JsonSchemaProperty>): Record<string, unknown> {
const result: Record<string, unknown> = { ...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<string, unknown>, 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<string, unknown>): Record<string, unknown> {
const jsonSchema = standardSchemaToJsonSchema(schema, 'input');
const properties = jsonSchema.properties as Record<string, JsonSchemaProperty> | undefined;
if (!properties) return args;
return coerceObject(args, properties);
}

// Validation

export type StandardSchemaValidationResult<T> = { success: true; data: T } | { success: false; error: string };
Expand Down
4 changes: 3 additions & 1 deletion packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
import {
assertCompleteRequestPrompt,
assertCompleteRequestResourceTemplate,
coerceToolArgs,
promptArgumentsFromStandardSchema,
ProtocolError,
ProtocolErrorCode,
Expand Down Expand Up @@ -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<string, unknown>);
const parseResult = await validateStandardSchema(tool.inputSchema, coercedArgs);
if (!parseResult.success) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
Expand Down
125 changes: 125 additions & 0 deletions test/integration/test/issues/test1361.tool-arg-type-coercion.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof McpServer.prototype.registerTool>[1] & { inputSchema: unknown },
args: Record<string, unknown>
) {
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);
});
});