Skip to content

Commit 2edee91

Browse files
committed
mcp: convert tool/prompt schemas eagerly at registration time
Currently `standardSchemaToJsonSchema()` is called lazily inside the `tools/list` request handler, re-converting every tool's schema on every list request. The same applies to prompts via `promptArgumentsFromStandardSchema()` in the `prompts/list` handler. Move the conversion to `_createRegisteredTool()` / `_createRegisteredPrompt()` and cache the result on `RegisteredTool` (`inputJsonSchema`, `outputJsonSchema`) and `RegisteredPrompt` (`cachedArguments`). The list handlers now read from these cached fields. The `update()` methods recompute the cache when schemas change. This: - Surfaces schema conversion errors (e.g. cycle detection from #1563) at dev time when the tool is registered, not at runtime when a client first calls `tools/list` - Avoids re-converting identical schemas on every `tools/list` / `prompts/list` call - Matches the Go SDK and FastMCP, which both process schemas at registration time Includes regression tests verifying eager conversion at registration, cached reuse across list calls, and cache invalidation on `update()` for both tools and prompts. Fixes #1847
1 parent 7ba58da commit 2edee91

2 files changed

Lines changed: 267 additions & 7 deletions

File tree

packages/server/src/server/mcp.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -137,20 +137,20 @@ export class McpServer {
137137
tools: Object.entries(this._registeredTools)
138138
.filter(([, tool]) => tool.enabled)
139139
.map(([name, tool]): Tool => {
140+
// Use the JSON Schema cached at registration / update
141+
// time instead of re-converting on every request.
140142
const toolDefinition: Tool = {
141143
name,
142144
title: tool.title,
143145
description: tool.description,
144-
inputSchema: tool.inputSchema
145-
? (standardSchemaToJsonSchema(tool.inputSchema, 'input') as Tool['inputSchema'])
146-
: EMPTY_OBJECT_JSON_SCHEMA,
146+
inputSchema: tool.inputJsonSchema ?? EMPTY_OBJECT_JSON_SCHEMA,
147147
annotations: tool.annotations,
148148
execution: tool.execution,
149149
_meta: tool._meta
150150
};
151151

152-
if (tool.outputSchema) {
153-
toolDefinition.outputSchema = standardSchemaToJsonSchema(tool.outputSchema, 'output') as Tool['outputSchema'];
152+
if (tool.outputJsonSchema) {
153+
toolDefinition.outputSchema = tool.outputJsonSchema;
154154
}
155155

156156
return toolDefinition;
@@ -526,11 +526,13 @@ export class McpServer {
526526
prompts: Object.entries(this._registeredPrompts)
527527
.filter(([, prompt]) => prompt.enabled)
528528
.map(([name, prompt]): Prompt => {
529+
// Use the prompt arguments cached at registration /
530+
// update time instead of recomputing on every request.
529531
return {
530532
name,
531533
title: prompt.title,
532534
description: prompt.description,
533-
arguments: prompt.argsSchema ? promptArgumentsFromStandardSchema(prompt.argsSchema) : undefined,
535+
arguments: prompt.cachedArguments,
534536
_meta: prompt._meta
535537
};
536538
})
@@ -703,6 +705,11 @@ export class McpServer {
703705
callback: PromptCallback<StandardSchemaWithJSON | undefined>,
704706
_meta: Record<string, unknown> | undefined
705707
): RegisteredPrompt {
708+
// Compute prompt arguments eagerly so any schema-conversion errors
709+
// surface at registration time and we don't recompute on every
710+
// `prompts/list` request.
711+
const cachedArguments = argsSchema ? promptArgumentsFromStandardSchema(argsSchema) : undefined;
712+
706713
// Track current schema and callback for handler regeneration
707714
let currentArgsSchema = argsSchema;
708715
let currentCallback = callback;
@@ -711,6 +718,7 @@ export class McpServer {
711718
title,
712719
description,
713720
argsSchema,
721+
cachedArguments,
714722
_meta,
715723
handler: createPromptHandler(name, argsSchema, callback),
716724
enabled: true,
@@ -730,6 +738,8 @@ export class McpServer {
730738
let needsHandlerRegen = false;
731739
if (updates.argsSchema !== undefined) {
732740
registeredPrompt.argsSchema = updates.argsSchema;
741+
// Re-cache prompt arguments alongside the schema update.
742+
registeredPrompt.cachedArguments = promptArgumentsFromStandardSchema(updates.argsSchema);
733743
currentArgsSchema = updates.argsSchema;
734744
needsHandlerRegen = true;
735745
}
@@ -778,6 +788,12 @@ export class McpServer {
778788
// Validate tool name according to SEP specification
779789
validateAndWarnToolName(name);
780790

791+
// Convert schemas to JSON Schema eagerly so any errors (e.g. cycle
792+
// detection) surface at registration time rather than on the first
793+
// `tools/list` request, and so we don't re-convert on every list call.
794+
const inputJsonSchema = inputSchema ? (standardSchemaToJsonSchema(inputSchema, 'input') as Tool['inputSchema']) : undefined;
795+
const outputJsonSchema = outputSchema ? (standardSchemaToJsonSchema(outputSchema, 'output') as Tool['outputSchema']) : undefined;
796+
781797
// Track current handler for executor regeneration
782798
let currentHandler = handler;
783799

@@ -786,6 +802,8 @@ export class McpServer {
786802
description,
787803
inputSchema,
788804
outputSchema,
805+
inputJsonSchema,
806+
outputJsonSchema,
789807
annotations,
790808
execution,
791809
_meta,
@@ -810,6 +828,9 @@ export class McpServer {
810828
let needsExecutorRegen = false;
811829
if (updates.paramsSchema !== undefined) {
812830
registeredTool.inputSchema = updates.paramsSchema;
831+
// Re-cache the JSON Schema; surfaces conversion errors
832+
// synchronously like the initial registration does.
833+
registeredTool.inputJsonSchema = standardSchemaToJsonSchema(updates.paramsSchema, 'input') as Tool['inputSchema'];
813834
needsExecutorRegen = true;
814835
}
815836
if (updates.callback !== undefined) {
@@ -821,7 +842,10 @@ export class McpServer {
821842
registeredTool.executor = createToolExecutor(registeredTool.inputSchema, currentHandler);
822843
}
823844

824-
if (updates.outputSchema !== undefined) registeredTool.outputSchema = updates.outputSchema;
845+
if (updates.outputSchema !== undefined) {
846+
registeredTool.outputSchema = updates.outputSchema;
847+
registeredTool.outputJsonSchema = standardSchemaToJsonSchema(updates.outputSchema, 'output') as Tool['outputSchema'];
848+
}
825849
if (updates.annotations !== undefined) registeredTool.annotations = updates.annotations;
826850
if (updates._meta !== undefined) registeredTool._meta = updates._meta;
827851
if (updates.enabled !== undefined) registeredTool.enabled = updates.enabled;
@@ -1094,6 +1118,18 @@ export type RegisteredTool = {
10941118
description?: string;
10951119
inputSchema?: StandardSchemaWithJSON;
10961120
outputSchema?: StandardSchemaWithJSON;
1121+
/**
1122+
* @hidden
1123+
* Cached JSON Schema computed from `inputSchema` at registration time.
1124+
* Re-computed when `update({ paramsSchema })` is called.
1125+
*/
1126+
inputJsonSchema?: Tool['inputSchema'];
1127+
/**
1128+
* @hidden
1129+
* Cached JSON Schema computed from `outputSchema` at registration time.
1130+
* Re-computed when `update({ outputSchema })` is called.
1131+
*/
1132+
outputJsonSchema?: Tool['outputSchema'];
10971133
annotations?: ToolAnnotations;
10981134
execution?: ToolExecution;
10991135
_meta?: Record<string, unknown>;
@@ -1240,6 +1276,12 @@ export type RegisteredPrompt = {
12401276
title?: string;
12411277
description?: string;
12421278
argsSchema?: StandardSchemaWithJSON;
1279+
/**
1280+
* @hidden
1281+
* Cached prompt arguments computed from `argsSchema` at registration time.
1282+
* Re-computed when `update({ argsSchema })` is called.
1283+
*/
1284+
cachedArguments?: Prompt['arguments'];
12431285
_meta?: Record<string, unknown>;
12441286
/** @hidden */
12451287
handler: PromptHandler;

test/integration/test/server/mcp.test.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2208,6 +2208,154 @@ describe('Zod v4', () => {
22082208
// Clean up spies
22092209
warnSpy.mockRestore();
22102210
});
2211+
2212+
/***
2213+
* Test: Eager schema conversion at registration time (#1847)
2214+
*
2215+
* Schemas should be converted to JSON Schema when the tool is
2216+
* registered, not lazily on every `tools/list` request. This
2217+
* surfaces conversion errors at dev time and avoids redundant
2218+
* work on hot paths.
2219+
*/
2220+
test('should convert tool schemas eagerly at registration time', async () => {
2221+
const mcpServer = new McpServer({
2222+
name: 'test server',
2223+
version: '1.0'
2224+
});
2225+
2226+
// Register a tool with both input and output schemas.
2227+
const tool = mcpServer.registerTool(
2228+
'eager',
2229+
{
2230+
inputSchema: z.object({ name: z.string() }),
2231+
outputSchema: z.object({ result: z.number() })
2232+
},
2233+
async () => ({
2234+
content: [{ type: 'text', text: '' }],
2235+
structuredContent: { result: 1 }
2236+
})
2237+
);
2238+
2239+
// The cached JSON Schemas should already be populated immediately
2240+
// after registration — no client connection required.
2241+
expect(tool.inputJsonSchema).toMatchObject({
2242+
type: 'object',
2243+
properties: { name: { type: 'string' } }
2244+
});
2245+
expect(tool.outputJsonSchema).toMatchObject({
2246+
type: 'object',
2247+
properties: { result: { type: 'number' } }
2248+
});
2249+
});
2250+
2251+
/***
2252+
* Test: tools/list returns identical cached schemas (#1847)
2253+
*
2254+
* Two consecutive `tools/list` calls should return the exact
2255+
* same JSON Schema content, proving the cached value is reused
2256+
* rather than re-converted.
2257+
*/
2258+
test('should reuse cached JSON Schema across tools/list calls', async () => {
2259+
const mcpServer = new McpServer({
2260+
name: 'test server',
2261+
version: '1.0'
2262+
});
2263+
const client = new Client({ name: 'test client', version: '1.0' });
2264+
2265+
mcpServer.registerTool(
2266+
'cached',
2267+
{
2268+
inputSchema: z.object({ name: z.string() }),
2269+
outputSchema: z.object({ result: z.number() })
2270+
},
2271+
async () => ({
2272+
content: [{ type: 'text', text: '' }],
2273+
structuredContent: { result: 1 }
2274+
})
2275+
);
2276+
2277+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
2278+
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
2279+
2280+
const first = await client.request({ method: 'tools/list' });
2281+
const second = await client.request({ method: 'tools/list' });
2282+
2283+
// Both responses should produce identical JSON Schemas.
2284+
expect(first.tools[0]!.inputSchema).toEqual(second.tools[0]!.inputSchema);
2285+
expect(first.tools[0]!.outputSchema).toEqual(second.tools[0]!.outputSchema);
2286+
expect(first.tools[0]!.inputSchema).toMatchObject({
2287+
properties: { name: { type: 'string' } }
2288+
});
2289+
});
2290+
2291+
/***
2292+
* Test: tool.update() re-caches JSON Schema (#1847)
2293+
*
2294+
* When a schema is replaced via `update()`, the cached JSON
2295+
* Schema must be recomputed so the next `tools/list` reflects
2296+
* the change.
2297+
*/
2298+
test('should re-cache JSON Schema when paramsSchema is updated', async () => {
2299+
const mcpServer = new McpServer({
2300+
name: 'test server',
2301+
version: '1.0'
2302+
});
2303+
2304+
const tool = mcpServer.registerTool('updatable', { inputSchema: z.object({ name: z.string() }) }, async () => ({
2305+
content: [{ type: 'text', text: '' }]
2306+
}));
2307+
2308+
expect(tool.inputJsonSchema).toMatchObject({
2309+
properties: { name: { type: 'string' } }
2310+
});
2311+
2312+
tool.update({
2313+
paramsSchema: z.object({ name: z.string(), value: z.number() }),
2314+
callback: async () => ({ content: [{ type: 'text', text: '' }] })
2315+
});
2316+
2317+
// The cached JSON Schema should now reflect the new shape.
2318+
expect(tool.inputJsonSchema).toMatchObject({
2319+
properties: {
2320+
name: { type: 'string' },
2321+
value: { type: 'number' }
2322+
}
2323+
});
2324+
});
2325+
2326+
/***
2327+
* Test: tool.update() re-caches outputSchema JSON Schema (#1847)
2328+
*/
2329+
test('should re-cache JSON Schema when outputSchema is updated', async () => {
2330+
const mcpServer = new McpServer({
2331+
name: 'test server',
2332+
version: '1.0'
2333+
});
2334+
2335+
const tool = mcpServer.registerTool('output-updatable', { outputSchema: z.object({ result: z.number() }) }, async () => ({
2336+
content: [{ type: 'text', text: '' }],
2337+
structuredContent: { result: 1 }
2338+
}));
2339+
2340+
expect(tool.outputJsonSchema).toMatchObject({
2341+
properties: { result: { type: 'number' } }
2342+
});
2343+
2344+
tool.update({
2345+
outputSchema: z.object({ result: z.number(), sum: z.number() }),
2346+
callback: async () => ({
2347+
content: [{ type: 'text', text: '' }],
2348+
structuredContent: { result: 1, sum: 2 }
2349+
})
2350+
});
2351+
2352+
expect(tool.outputJsonSchema).toMatchObject({
2353+
properties: {
2354+
result: { type: 'number' },
2355+
sum: { type: 'number' }
2356+
}
2357+
});
2358+
});
22112359
});
22122360

22132361
describe('resource()', () => {
@@ -4346,6 +4494,76 @@ describe('Zod v4', () => {
43464494
expect(result.prompts[0]!.name).toBe('test-without-meta');
43474495
expect(result.prompts[0]!._meta).toBeUndefined();
43484496
});
4497+
4498+
/***
4499+
* Test: Eager prompt argument computation at registration (#1847)
4500+
*
4501+
* Like tools, prompt arguments should be computed once at
4502+
* registration time and reused on every `prompts/list` request.
4503+
*/
4504+
test('should compute prompt arguments eagerly at registration time', async () => {
4505+
const mcpServer = new McpServer({
4506+
name: 'test server',
4507+
version: '1.0'
4508+
});
4509+
4510+
const prompt = mcpServer.registerPrompt(
4511+
'eager-prompt',
4512+
{
4513+
argsSchema: z.object({
4514+
name: z.string().describe('user name'),
4515+
age: z.number().optional()
4516+
})
4517+
},
4518+
async () => ({
4519+
messages: [{ role: 'user', content: { type: 'text', text: '' } }]
4520+
})
4521+
);
4522+
4523+
// The cached arguments should already be populated immediately
4524+
// after registration.
4525+
expect(prompt.cachedArguments).toEqual([
4526+
{ name: 'name', description: 'user name', required: true },
4527+
{ name: 'age', description: undefined, required: false }
4528+
]);
4529+
});
4530+
4531+
/***
4532+
* Test: prompt.update() re-caches arguments (#1847)
4533+
*/
4534+
test('should re-cache prompt arguments when argsSchema is updated', async () => {
4535+
const mcpServer = new McpServer({
4536+
name: 'test server',
4537+
version: '1.0'
4538+
});
4539+
4540+
const prompt = mcpServer.registerPrompt(
4541+
'updatable-prompt',
4542+
{
4543+
argsSchema: z.object({ name: z.string() })
4544+
},
4545+
async () => ({
4546+
messages: [{ role: 'user', content: { type: 'text', text: '' } }]
4547+
})
4548+
);
4549+
4550+
expect(prompt.cachedArguments).toEqual([{ name: 'name', description: undefined, required: true }]);
4551+
4552+
prompt.update({
4553+
argsSchema: z.object({
4554+
name: z.string(),
4555+
extra: z.string().optional()
4556+
}),
4557+
callback: async () => ({
4558+
messages: [{ role: 'user', content: { type: 'text', text: '' } }]
4559+
})
4560+
});
4561+
4562+
expect(prompt.cachedArguments).toEqual([
4563+
{ name: 'name', description: undefined, required: true },
4564+
{ name: 'extra', description: undefined, required: false }
4565+
]);
4566+
});
43494567
});
43504568

43514569
describe('Tool title precedence', () => {

0 commit comments

Comments
 (0)