Skip to content

Commit 23e5230

Browse files
authored
fix: repair tool schemas before AJV compilation to prevent MissingRefError (#355)
The MCP SDK (≥1.27) introduced Client.cacheToolMetadata() which eagerly compiles outputSchema with AJV during listTools(). When the Stitch backend returns schemas with $ref to missing $defs (e.g. #/$defs/ScreenInstance), AJV throws MissingRefError before the SDK's schema repair code can run. Fix: - Extract schema repair into a standalone module (schema-repair.ts) that scans for $ref targets and injects well-known stub definitions - In StitchToolClient.listTools(): use raw request() instead of Client.listTools() to bypass AJV compilation, then apply repair - In proxy refreshTools(): apply repair before re-serving tools to MCP clients whose AJV validators would also crash The repair module: - Scans both inputSchema and outputSchema for $ref targets - Only injects stubs for referenced-but-missing well-known defs - Never overwrites existing $defs - Handles ScreenInstance, SelectedScreenInstance, and File types Includes 13 unit tests covering injection, preservation, nesting, unknown refs, and null safety.
1 parent 2733a75 commit 23e5230

5 files changed

Lines changed: 410 additions & 55 deletions

File tree

packages/sdk/src/client.ts

Lines changed: 18 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
1616
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
17+
import { ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js";
1718
import {
1819
StitchConfigSchema,
1920
StitchConfig,
@@ -23,6 +24,7 @@ import {
2324
import { StitchError, StitchErrorCode } from "./spec/errors.js";
2425
import { buildAuthHeaders as buildBaseAuthHeaders } from "./auth.js";
2526
import { SDK_VERSION } from "./version.js";
27+
import { repairToolSchemas } from "./schema-repair.js";
2628

2729
/**
2830
* Authenticated tool pipe for the Stitch MCP Server.
@@ -235,63 +237,25 @@ export class StitchToolClient implements StitchToolClientSpec {
235237

236238
async listTools() {
237239
if (!this.isConnected) await this.connect();
238-
const remoteTools = await this.client.listTools();
240+
241+
// CRITICAL: We use a raw request() instead of this.client.listTools()
242+
// because Client.listTools() eagerly compiles outputSchema with AJV
243+
// via cacheToolMetadata(). If the Stitch backend returns schemas with
244+
// $ref to missing $defs (e.g. #/$defs/ScreenInstance), AJV throws a
245+
// MissingRefError BEFORE our schema repair code can run.
246+
//
247+
// By using request() directly, we get the raw tool list, apply schema
248+
// repair to inject missing $defs, and avoid the AJV crash entirely.
249+
const remoteTools = await (this.client as any).request(
250+
{ method: 'tools/list', params: {} },
251+
ListToolsResultSchema,
252+
);
239253

240254
const tools = remoteTools.tools || [];
241255

242-
// Resilient Schema Repair: Dynamically patch broken $ref schemas from the Stitch backend
243-
for (const tool of tools) {
244-
const schema = tool.inputSchema as any;
245-
if (schema && typeof schema === 'object') {
246-
schema.$defs = schema.$defs || {};
247-
248-
// Inject the missing ScreenInstance definition if referenced
249-
if (!schema.$defs.ScreenInstance) {
250-
schema.$defs.ScreenInstance = {
251-
type: 'object',
252-
description: 'An instance of a screen on the project.',
253-
properties: {
254-
groupId: { type: 'string' },
255-
groupName: { type: 'string' },
256-
height: { type: 'integer', format: 'int32' },
257-
hidden: { type: 'boolean' },
258-
id: { type: 'string' },
259-
isFavourite: { type: 'boolean' },
260-
label: { type: 'string' },
261-
sourceAsset: { type: 'string' },
262-
sourceScreen: { type: 'string' },
263-
type: {
264-
type: 'string',
265-
enum: [
266-
'SCREEN_INSTANCE_TYPE_UNSPECIFIED',
267-
'SCREEN_INSTANCE',
268-
'DESIGN_SYSTEM_INSTANCE',
269-
'GROUP_INSTANCE'
270-
]
271-
},
272-
width: { type: 'integer', format: 'int32' },
273-
x: { type: 'integer', format: 'int32' },
274-
y: { type: 'integer', format: 'int32' }
275-
}
276-
};
277-
}
278-
279-
// Inject the missing File definition if referenced
280-
if (!schema.$defs.File) {
281-
schema.$defs.File = {
282-
type: 'object',
283-
description: 'A File resource.',
284-
properties: {
285-
downloadUrl: { type: 'string' },
286-
fileContentBase64: { type: 'string', writeOnly: true },
287-
mimeType: { type: 'string' },
288-
name: { type: 'string' },
289-
uploadBlobId: { type: 'string' }
290-
}
291-
};
292-
}
293-
}
294-
}
256+
// Resilient Schema Repair: Inject missing $defs BEFORE any AJV
257+
// compilation can occur. Repairs both inputSchema and outputSchema.
258+
repairToolSchemas(tools);
295259

296260
const localTools = this.localVirtualTools.map(t => ({
297261
name: t.name,

packages/sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export { DesignSystem } from "../generated/src/designsystem.js";
2222
// Infrastructure (handwritten)
2323
export { StitchToolClient } from "./client.js";
2424
export { StitchProxy } from "./proxy/core.js";
25+
export { repairToolSchemas, repairSchema } from "./schema-repair.js";
2526

2627
// Virtual Tools
2728
export {

packages/sdk/src/proxy/client.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import { StitchProxyConfig } from '../spec/proxy.js';
1616
import { buildAuthHeaders } from '../auth.js';
1717
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
18+
import { repairToolSchemas } from '../schema-repair.js';
1819

1920
/**
2021
* Shared state for proxy handlers.
@@ -111,10 +112,16 @@ export async function initializeStitchConnection(
111112

112113
/**
113114
* Refresh the cached tools list from Stitch.
115+
*
116+
* Applies schema repair to inject missing $defs before the tools are
117+
* re-served to MCP clients whose AJV validators would otherwise crash
118+
* on unresolved $ref targets.
114119
*/
115120
export async function refreshTools(ctx: ProxyContext): Promise<void> {
116121
const toolsResult = (await forwardToStitch(ctx.config, 'tools/list', {})) as {
117122
tools: Tool[];
118123
};
119-
ctx.remoteTools = toolsResult.tools || [];
124+
const tools = toolsResult.tools || [];
125+
repairToolSchemas(tools);
126+
ctx.remoteTools = tools;
120127
}

packages/sdk/src/schema-repair.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
16+
17+
/**
18+
* Well-known $defs definitions that the Stitch backend may reference via
19+
* $ref but omit from the schema's $defs block. When the MCP SDK's
20+
* AJV validator tries to compile these schemas, the missing references
21+
* cause a hard crash (`MissingRefError`).
22+
*
23+
* This registry lets us inject stub definitions *before* AJV ever sees
24+
* the schema, making the repair order-independent of the MCP SDK version.
25+
*/
26+
const WELL_KNOWN_DEFS: Record<string, object> = {
27+
ScreenInstance: {
28+
type: 'object',
29+
description: 'An instance of a screen on the project.',
30+
properties: {
31+
groupId: { type: 'string' },
32+
groupName: { type: 'string' },
33+
height: { type: 'integer', format: 'int32' },
34+
hidden: { type: 'boolean' },
35+
id: { type: 'string' },
36+
isFavourite: { type: 'boolean' },
37+
label: { type: 'string' },
38+
sourceAsset: { type: 'string' },
39+
sourceScreen: { type: 'string' },
40+
type: {
41+
type: 'string',
42+
enum: [
43+
'SCREEN_INSTANCE_TYPE_UNSPECIFIED',
44+
'SCREEN_INSTANCE',
45+
'DESIGN_SYSTEM_INSTANCE',
46+
'GROUP_INSTANCE',
47+
],
48+
},
49+
width: { type: 'integer', format: 'int32' },
50+
x: { type: 'integer', format: 'int32' },
51+
y: { type: 'integer', format: 'int32' },
52+
},
53+
},
54+
55+
SelectedScreenInstance: {
56+
type: 'object',
57+
description: 'A selected screen instance reference.',
58+
properties: {
59+
screenId: { type: 'string' },
60+
instanceId: { type: 'string' },
61+
},
62+
},
63+
64+
File: {
65+
type: 'object',
66+
description: 'A File resource.',
67+
properties: {
68+
downloadUrl: { type: 'string' },
69+
fileContentBase64: { type: 'string' },
70+
mimeType: { type: 'string' },
71+
name: { type: 'string' },
72+
uploadBlobId: { type: 'string' },
73+
},
74+
},
75+
};
76+
77+
/**
78+
* Collect every `$ref` target of the form `#/$defs/<Name>` from a schema
79+
* object (recursively). Returns the set of referenced definition names.
80+
*/
81+
function collectRefTargets(obj: unknown, refs: Set<string> = new Set()): Set<string> {
82+
if (obj === null || typeof obj !== 'object') return refs;
83+
84+
if (Array.isArray(obj)) {
85+
for (const item of obj) collectRefTargets(item, refs);
86+
return refs;
87+
}
88+
89+
const record = obj as Record<string, unknown>;
90+
if (typeof record.$ref === 'string') {
91+
const match = record.$ref.match(/^#\/\$defs\/(.+)$/);
92+
if (match) refs.add(match[1]);
93+
}
94+
95+
for (const value of Object.values(record)) {
96+
collectRefTargets(value, refs);
97+
}
98+
99+
return refs;
100+
}
101+
102+
/**
103+
* Repair a single JSON Schema by injecting any missing well-known $defs
104+
* that are referenced via $ref but not present.
105+
*
106+
* Mutates the schema in place and returns it for convenience.
107+
*/
108+
export function repairSchema(schema: Record<string, any>): Record<string, any> {
109+
if (!schema || typeof schema !== 'object') return schema;
110+
111+
const referencedDefs = collectRefTargets(schema);
112+
if (referencedDefs.size === 0) return schema;
113+
114+
// Ensure $defs block exists
115+
schema.$defs = schema.$defs || {};
116+
117+
for (const defName of referencedDefs) {
118+
// Only inject if: (a) the def is missing, and (b) we have a well-known stub
119+
if (!schema.$defs[defName] && WELL_KNOWN_DEFS[defName]) {
120+
schema.$defs[defName] = { ...WELL_KNOWN_DEFS[defName] };
121+
}
122+
}
123+
124+
return schema;
125+
}
126+
127+
/**
128+
* Apply schema repair to every tool's inputSchema and outputSchema.
129+
*
130+
* This MUST run before the MCP SDK's AJV validator sees the schemas.
131+
* Mutates tools in place.
132+
*/
133+
export function repairToolSchemas(tools: Tool[]): void {
134+
for (const tool of tools) {
135+
if (tool.inputSchema && typeof tool.inputSchema === 'object') {
136+
repairSchema(tool.inputSchema as Record<string, any>);
137+
}
138+
// outputSchema was added in MCP SDK ≥1.27 and is the primary crash vector:
139+
// Client.cacheToolMetadata() eagerly compiles outputSchema with AJV.
140+
const anyTool = tool as any;
141+
if (anyTool.outputSchema && typeof anyTool.outputSchema === 'object') {
142+
repairSchema(anyTool.outputSchema);
143+
}
144+
}
145+
}

0 commit comments

Comments
 (0)