@pi-archimedes/mcp 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/package.json +39 -0
  4. package/src/auth-flow.test.ts +583 -0
  5. package/src/auth-flow.ts +310 -0
  6. package/src/auth-run.test.ts +309 -0
  7. package/src/auth-run.ts +146 -0
  8. package/src/auth-storage.test.ts +338 -0
  9. package/src/auth-storage.ts +330 -0
  10. package/src/auto-auth.test.ts +231 -0
  11. package/src/auto-auth.ts +135 -0
  12. package/src/callback-server.test.ts +446 -0
  13. package/src/callback-server.ts +538 -0
  14. package/src/commands-auth.test.ts +320 -0
  15. package/src/commands-auth.ts +128 -0
  16. package/src/commands.test.ts +834 -0
  17. package/src/commands.ts +424 -0
  18. package/src/config-write.test.ts +213 -0
  19. package/src/config-write.ts +207 -0
  20. package/src/config.test.ts +468 -0
  21. package/src/config.ts +278 -0
  22. package/src/direct-tools.test.ts +473 -0
  23. package/src/direct-tools.ts +250 -0
  24. package/src/host-configs.test.ts +231 -0
  25. package/src/host-configs.ts +106 -0
  26. package/src/index.test.ts +689 -0
  27. package/src/index.ts +146 -0
  28. package/src/lifecycle.test.ts +274 -0
  29. package/src/lifecycle.ts +77 -0
  30. package/src/metadata-cache.test.ts +383 -0
  31. package/src/metadata-cache.ts +231 -0
  32. package/src/npx-resolver.test.ts +142 -0
  33. package/src/npx-resolver.ts +126 -0
  34. package/src/oauth-provider.test.ts +404 -0
  35. package/src/oauth-provider.ts +197 -0
  36. package/src/oauth-types.ts +54 -0
  37. package/src/panel-rows.ts +210 -0
  38. package/src/panel.test.ts +298 -0
  39. package/src/panel.ts +742 -0
  40. package/src/proxy-tool.ts +524 -0
  41. package/src/renderer.test.ts +326 -0
  42. package/src/renderer.ts +239 -0
  43. package/src/schema-validator.test.ts +56 -0
  44. package/src/schema-validator.ts +42 -0
  45. package/src/server-client.test.ts +1001 -0
  46. package/src/server-client.ts +576 -0
  47. package/src/server-manager.ts +139 -0
  48. package/src/setup-panel.test.ts +162 -0
  49. package/src/setup-panel.ts +715 -0
  50. package/src/tool-naming.test.ts +168 -0
  51. package/src/tool-naming.ts +114 -0
  52. package/src/types.ts +162 -0
@@ -0,0 +1,326 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ renderDirectCall,
5
+ renderDirectResult,
6
+ renderProxyCall,
7
+ renderProxyResult,
8
+ extractServerName,
9
+ formatProxyCallServer,
10
+ } from "./renderer.js";
11
+
12
+ // ── Mocks ───────────────────────────────────────────────────────────────────
13
+
14
+ vi.mock("@earendil-works/pi-tui", () => {
15
+ class MockText {
16
+ private _content = "";
17
+
18
+ constructor(initial: string = "") {
19
+ this._content = initial;
20
+ }
21
+
22
+ setText(content: string): void {
23
+ this._content = content;
24
+ }
25
+
26
+ getContent(): string {
27
+ return this._content;
28
+ }
29
+ }
30
+ return {
31
+ Text: MockText,
32
+ };
33
+ });
34
+
35
+ // Same class the renderer's `instanceof Text` check sees (the mock).
36
+ import { Text as MockText } from "@earendil-works/pi-tui";
37
+
38
+ /** The runtime mock exposes getContent(); the real Text type does not. */
39
+ type MockTextShim = { getContent(): string };
40
+
41
+ // Fake theme: wraps text in visible markers so assertions can verify which
42
+ // color token each fragment used.
43
+ const theme = {
44
+ fg: (token: string, text?: string) =>
45
+ text === undefined ? `[${token}]` : `[${token}:${text}]`,
46
+ bold: (text: string) => `**${text}**`,
47
+ } as unknown as Theme;
48
+
49
+ // A theme whose fg() throws — renderers must never propagate errors.
50
+ const throwingTheme = {
51
+ fg: () => {
52
+ throw new Error("theme exploded");
53
+ },
54
+ bold: (text: string) => text,
55
+ } as unknown as Theme;
56
+
57
+ // ── Shared fixtures ─────────────────────────────────────────────────────────
58
+
59
+ const TOOL = "postgres_describe_table";
60
+ const SERVER = "postgres";
61
+ const ARGS = { schema: "public", table: "model_files" };
62
+ const RESULT = { content: [{ type: "text", text: "Hello\nWorld" }] };
63
+
64
+ // Line 1 header: blue bold "mcp" + orange server name.
65
+ const HEADER = `[toolTitle:**mcp**] [accent:${SERVER}]`;
66
+
67
+ function ctx(extra: Record<string, unknown> = {}): Record<string, unknown> {
68
+ return { ...extra };
69
+ }
70
+
71
+ // ── extractServerName ────────────────────────────────────────────────────────
72
+
73
+ describe("extractServerName", () => {
74
+ it("takes the first segment before the underscore", () => {
75
+ expect(extractServerName("atlassian_searchJiraIssuesUsingJql")).toBe(
76
+ "atlassian",
77
+ );
78
+ expect(extractServerName("postgres_describe_table")).toBe("postgres");
79
+ });
80
+
81
+ it("returns the whole name when there is no underscore", () => {
82
+ expect(extractServerName("mcp")).toBe("mcp");
83
+ });
84
+ });
85
+
86
+ // ── formatProxyCallServer ────────────────────────────────────────────────────
87
+
88
+ describe("formatProxyCallServer", () => {
89
+ it("extracts the server from the tool name", () => {
90
+ expect(formatProxyCallServer({ tool: "atlassian_search" })).toBe(
91
+ "atlassian",
92
+ );
93
+ });
94
+
95
+ it("prefers explicit args.server", () => {
96
+ expect(formatProxyCallServer({ tool: "atlassian_search", server: "s1" })).toBe(
97
+ "atlassian",
98
+ );
99
+ expect(formatProxyCallServer({ server: "s1" })).toBe("s1");
100
+ });
101
+
102
+ it("falls back to action words for non-tool calls", () => {
103
+ expect(formatProxyCallServer({ search: "jira" })).toBe("search");
104
+ expect(formatProxyCallServer({ describe: "t1" })).toBe("describe");
105
+ expect(formatProxyCallServer({ connect: "s1" })).toBe("connect");
106
+ expect(formatProxyCallServer({ action: "weird" })).toBe("weird");
107
+ expect(formatProxyCallServer({})).toBe("status");
108
+ });
109
+ });
110
+
111
+ // ── renderDirectCall ────────────────────────────────────────────────────────
112
+
113
+ describe("renderDirectCall", () => {
114
+ const content = (c: unknown) => (c as unknown as MockTextShim).getContent();
115
+
116
+ it("renders the header only: blue mcp + orange server name", () => {
117
+ const out = renderDirectCall(TOOL, { ...ARGS }, theme, ctx());
118
+ expect(content(out)).toBe(HEADER);
119
+ });
120
+
121
+ it("never throws — degrades to plain text when the theme throws", () => {
122
+ const out = renderDirectCall(TOOL, { ...ARGS }, throwingTheme, ctx());
123
+ expect(content(out)).toBe(`mcp ${TOOL}`);
124
+ });
125
+
126
+ it("reuses the lastComponent instance", () => {
127
+ const last = new MockText("stale");
128
+ const out = renderDirectCall(TOOL, { ...ARGS }, theme, ctx({ lastComponent: last }));
129
+ expect(out).toBe(last);
130
+ expect(content(out)).toBe(HEADER);
131
+ });
132
+ });
133
+
134
+ // ── renderDirectResult ──────────────────────────────────────────────────────
135
+
136
+ describe("renderDirectResult", () => {
137
+ const content = (c: unknown) => (c as unknown as MockTextShim).getContent();
138
+
139
+ it("collapsed success: green tick + muted tool name, NO result text", () => {
140
+ const out = renderDirectResult(
141
+ TOOL,
142
+ RESULT,
143
+ { expanded: false },
144
+ theme,
145
+ ctx({ isError: false, args: { ...ARGS } }),
146
+ );
147
+ expect(content(out)).toBe(`[success:✓ ][muted:${TOOL}]`);
148
+ expect(content(out)).not.toContain("Hello");
149
+ });
150
+
151
+ it("collapsed error: red cross + muted tool name", () => {
152
+ const out = renderDirectResult(
153
+ TOOL,
154
+ RESULT,
155
+ { expanded: false },
156
+ theme,
157
+ ctx({ isError: true, args: { ...ARGS } }),
158
+ );
159
+ expect(content(out)).toBe(`[error:✗ ][muted:${TOOL}]`);
160
+ });
161
+
162
+ it("isPartial: running glyph (muted) + muted tool name, no content", () => {
163
+ const out = renderDirectResult(
164
+ TOOL,
165
+ RESULT,
166
+ { isPartial: true },
167
+ theme,
168
+ ctx({ isError: false, args: { ...ARGS } }),
169
+ );
170
+ expect(content(out)).toBe(`[muted:▸ ][muted:${TOOL}]`);
171
+ });
172
+
173
+ it("expanded success: dim args JSON + blank line + full text", () => {
174
+ const out = renderDirectResult(
175
+ TOOL,
176
+ RESULT,
177
+ { expanded: true },
178
+ theme,
179
+ ctx({ isError: false, args: { ...ARGS } }),
180
+ );
181
+ expect(content(out)).toBe(
182
+ `[dim:{\n "schema": "public",\n "table": "model_files"\n}]\n\n` +
183
+ `[toolOutput:Hello]\n[toolOutput:World]`,
184
+ );
185
+ });
186
+
187
+ it("expanded error: full text in error colour", () => {
188
+ const out = renderDirectResult(
189
+ TOOL,
190
+ { content: [{ type: "text", text: "boom" }] },
191
+ { expanded: true },
192
+ theme,
193
+ ctx({ isError: true, args: { ...ARGS } }),
194
+ );
195
+ expect(content(out)).toBe(
196
+ `[dim:{\n "schema": "public",\n "table": "model_files"\n}]\n\n[error:boom]`,
197
+ );
198
+ });
199
+
200
+ it("expanded empty content: (empty result) after the args block", () => {
201
+ const out = renderDirectResult(
202
+ TOOL,
203
+ { content: [] },
204
+ { expanded: true },
205
+ theme,
206
+ ctx({ isError: false, args: { ...ARGS } }),
207
+ );
208
+ expect(content(out)).toBe(
209
+ `[dim:{\n "schema": "public",\n "table": "model_files"\n}]\n\n` +
210
+ `[muted:(empty result)]`,
211
+ );
212
+ });
213
+
214
+ it("expanded honours context.expanded when options.expanded is unset", () => {
215
+ const out = renderDirectResult(
216
+ TOOL,
217
+ RESULT,
218
+ {},
219
+ theme,
220
+ ctx({ isError: false, expanded: true, args: { ...ARGS } }),
221
+ );
222
+ expect(content(out)).toContain("[toolOutput:Hello]");
223
+ });
224
+
225
+ it("never throws — degrades to empty text when the theme throws", () => {
226
+ const out = renderDirectResult(
227
+ TOOL,
228
+ RESULT,
229
+ { expanded: false },
230
+ throwingTheme,
231
+ ctx({ isError: false, args: { ...ARGS } }),
232
+ );
233
+ expect(content(out)).toBe("");
234
+ });
235
+
236
+ it("reuses the lastComponent instance", () => {
237
+ const last = new MockText("stale");
238
+ const out = renderDirectResult(
239
+ TOOL,
240
+ RESULT,
241
+ { expanded: false },
242
+ theme,
243
+ ctx({ isError: false, args: { ...ARGS }, lastComponent: last }),
244
+ );
245
+ expect(out).toBe(last);
246
+ expect(content(out)).toBe(`[success:✓ ][muted:${TOOL}]`);
247
+ });
248
+ });
249
+
250
+ // ── renderProxyCall (gateway) ───────────────────────────────────────────────
251
+
252
+ describe("renderProxyCall", () => {
253
+ const content = (c: unknown) => (c as unknown as MockTextShim).getContent();
254
+ const PROXY_ARGS = {
255
+ tool: "postgres_describe_table",
256
+ args: { table: "model_files" },
257
+ };
258
+
259
+ it("renders the header: blue mcp + orange server (from tool name)", () => {
260
+ const out = renderProxyCall({ ...PROXY_ARGS }, theme, ctx());
261
+ expect(content(out)).toBe(HEADER);
262
+ });
263
+
264
+ it("search action: header shows the action word", () => {
265
+ const out = renderProxyCall({ search: "jira" }, theme, ctx());
266
+ expect(content(out)).toBe("[toolTitle:**mcp**] [accent:search]");
267
+ });
268
+
269
+ it("never throws — degrades to plain 'mcp' when the theme throws", () => {
270
+ const out = renderProxyCall({ ...PROXY_ARGS }, throwingTheme, ctx());
271
+ expect(content(out)).toBe("mcp");
272
+ });
273
+ });
274
+
275
+ // ── renderProxyResult (gateway) ─────────────────────────────────────────────
276
+
277
+ describe("renderProxyResult", () => {
278
+ const content = (c: unknown) => (c as unknown as MockTextShim).getContent();
279
+ const PROXY_CONTEXT_ARGS = {
280
+ tool: "postgres_describe_table",
281
+ args: { sql: "SELECT 1" },
282
+ };
283
+
284
+ it("collapsed success: green tick + muted tool name", () => {
285
+ const out = renderProxyResult(
286
+ { content: [{ type: "text", text: "out" }] },
287
+ { expanded: false },
288
+ theme,
289
+ ctx({ isError: false, args: { ...PROXY_CONTEXT_ARGS } }),
290
+ );
291
+ expect(content(out)).toBe(`[success:✓ ][muted:postgres_describe_table]`);
292
+ });
293
+
294
+ it("collapsed error: red cross + muted tool name", () => {
295
+ const out = renderProxyResult(
296
+ { content: [{ type: "text", text: "out" }] },
297
+ { expanded: false },
298
+ theme,
299
+ ctx({ isError: true, args: { ...PROXY_CONTEXT_ARGS } }),
300
+ );
301
+ expect(content(out)).toBe(`[error:✗ ][muted:postgres_describe_table]`);
302
+ });
303
+
304
+ it("expanded: formats ONLY the nested args.args (not the gateway args)", () => {
305
+ const out = renderProxyResult(
306
+ { content: [{ type: "text", text: "out" }] },
307
+ { expanded: true },
308
+ theme,
309
+ ctx({ isError: false, args: { ...PROXY_CONTEXT_ARGS } }),
310
+ );
311
+ expect(content(out)).toBe(
312
+ `[dim:{\n "sql": "SELECT 1"\n}]\n\n[toolOutput:out]`,
313
+ );
314
+ expect(content(out)).not.toContain("describe_table\"");
315
+ });
316
+
317
+ it("no tool name (search action): falls back to 'mcp'", () => {
318
+ const out = renderProxyResult(
319
+ { content: [{ type: "text", text: "ok" }] },
320
+ { expanded: false },
321
+ theme,
322
+ ctx({ isError: false, args: { search: "jira" } }),
323
+ );
324
+ expect(content(out)).toBe(`[success:✓ ][muted:mcp]`);
325
+ });
326
+ });
@@ -0,0 +1,239 @@
1
+ import { Text } from "@earendil-works/pi-tui";
2
+ import type { Component } from "@earendil-works/pi-tui";
3
+
4
+ import type { Theme } from "@earendil-works/pi-coding-agent";
5
+
6
+ import { renderToolHeader, renderStatusLabel } from "@pi-archimedes/core/tool-render";
7
+
8
+ // Local aliases — the real ToolRenderContext has many more fields but we
9
+ // only use these in the renderer. Using a local type avoids over-constraining
10
+ // signatures and keeps the renderer tolerant of older pi versions (missing
11
+ // fields simply read as undefined). Exported so the registration wiring can
12
+ // cast pi's untyped context to the same loose shape.
13
+ export type RenderContext = {
14
+ lastComponent?: Component;
15
+ isError?: boolean;
16
+ expanded?: boolean;
17
+ isPartial?: boolean;
18
+ argsComplete?: boolean;
19
+ executionStarted?: boolean;
20
+ args?: unknown;
21
+ state?: Record<string, unknown>;
22
+ };
23
+
24
+ type ToolResult = {
25
+ content: Array<{ type: string; text?: string }>;
26
+ details?: Record<string, unknown>;
27
+ };
28
+
29
+ type RenderOptions = { expanded?: boolean; isPartial?: boolean };
30
+
31
+ // ── Public API ───────────────────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Extract server name from a tool name (first segment before "_").
35
+ * e.g. "atlassian_searchJiraIssuesUsingJql" → "atlassian"
36
+ * Falls back to the full name if no underscore.
37
+ */
38
+ export function extractServerName(toolName: string): string {
39
+ const idx = toolName.indexOf("_");
40
+ return idx === -1 ? toolName : toolName.slice(0, idx);
41
+ }
42
+
43
+ /**
44
+ * Get the server label for the mcp proxy call header.
45
+ * Uses args.server if provided, otherwise extracts from args.tool.
46
+ * Falls back to a generic action word for non-tool calls.
47
+ */
48
+ export function formatProxyCallServer(args: {
49
+ tool?: string;
50
+ search?: string;
51
+ describe?: string;
52
+ connect?: string;
53
+ server?: string;
54
+ action?: string;
55
+ }): string {
56
+ if (args.tool) return extractServerName(args.tool);
57
+ if (args.server) return args.server;
58
+ if (args.search) return `search`;
59
+ if (args.describe) return `describe`;
60
+ if (args.connect) return `connect`;
61
+ if (args.action) return args.action;
62
+ return "status";
63
+ }
64
+
65
+ /**
66
+ * Render the mcp proxy tool call row.
67
+ *
68
+ * Line 1: `mcp` (bold toolTitle) + server name (accent)
69
+ *
70
+ * Never throws.
71
+ */
72
+ export function renderProxyCall(
73
+ args: Record<string, unknown>,
74
+ theme: Theme,
75
+ context: RenderContext,
76
+ ): Component {
77
+ const text = reuseText(context);
78
+ try {
79
+ const server = formatProxyCallServer(
80
+ args as Parameters<typeof formatProxyCallServer>[0],
81
+ );
82
+ text.setText(renderToolHeader("mcp", server, theme));
83
+ } catch {
84
+ try {
85
+ text.setText("mcp");
86
+ } catch {
87
+ // keep whatever the component last rendered
88
+ }
89
+ }
90
+ return text;
91
+ }
92
+
93
+ /**
94
+ * Render the mcp proxy tool result row.
95
+ *
96
+ * - isPartial (streaming partial, defensive): ▸ tool name muted
97
+ * - collapsed (default): ▸/✓/✗ + full tool name (muted/success/error)
98
+ * - expanded: nested args as dim JSON + full result text
99
+ *
100
+ * Never throws.
101
+ */
102
+ export function renderProxyResult(
103
+ result: ToolResult,
104
+ options: RenderOptions,
105
+ theme: Theme,
106
+ context: RenderContext,
107
+ ): Component {
108
+ const args = isPlainObject(context.args) ? context.args : null;
109
+ const toolName = (args?.["tool"] as string | undefined) ?? "mcp";
110
+ const expandedArgs = args ? args["args"] : undefined;
111
+ return renderStatusLine(result, options, theme, context, toolName, expandedArgs);
112
+ }
113
+
114
+ /**
115
+ * Render a direct tool call row (e.g. atlassian_searchJiraIssuesUsingJql).
116
+ *
117
+ * Line 1: `mcp` (bold toolTitle) + server name (accent)
118
+ *
119
+ * Never throws.
120
+ */
121
+ export function renderDirectCall(
122
+ displayName: string,
123
+ args: Record<string, unknown>,
124
+ theme: Theme,
125
+ context: RenderContext,
126
+ ): Component {
127
+ const text = reuseText(context);
128
+ try {
129
+ const server = extractServerName(displayName);
130
+ text.setText(renderToolHeader("mcp", server, theme));
131
+ } catch {
132
+ try {
133
+ text.setText(`mcp ${displayName}`);
134
+ } catch {
135
+ // keep whatever the component last rendered
136
+ }
137
+ }
138
+ return text;
139
+ }
140
+
141
+ /**
142
+ * Render a direct tool result row.
143
+ * ▸/✓/✗ + full display name (muted/success/error)
144
+ */
145
+ export function renderDirectResult(
146
+ displayName: string,
147
+ result: ToolResult,
148
+ options: RenderOptions,
149
+ theme: Theme,
150
+ context: RenderContext,
151
+ ): Component {
152
+ const args = isPlainObject(context.args) ? context.args : null;
153
+ return renderStatusLine(result, options, theme, context, displayName, args);
154
+ }
155
+
156
+ // ── Shared renderer core ─────────────────────────────────────────────────────
157
+
158
+ /**
159
+ * Render the result row as:
160
+ * ▸ toolName (muted, while partial/running)
161
+ * ✓ toolName (success, green)
162
+ * ✗ toolName (error, red)
163
+ * When expanded: dim JSON args block + full result text.
164
+ */
165
+ function renderStatusLine(
166
+ result: ToolResult,
167
+ options: RenderOptions,
168
+ theme: Theme,
169
+ context: RenderContext,
170
+ toolName: string,
171
+ expandedArgs: unknown,
172
+ ): Component {
173
+ const text = reuseText(context);
174
+ try {
175
+ const expanded = options.expanded ?? context.expanded ?? false;
176
+
177
+ if (expanded) {
178
+ const parts: string[] = [];
179
+ if (expandedArgs !== undefined && expandedArgs !== null) {
180
+ const block = formatArgs(expandedArgs, 1200);
181
+ if (block) parts.push(theme.fg("dim", block));
182
+ }
183
+ const lines = result.content
184
+ .filter((b) => b.type === "text")
185
+ .flatMap((b) => (b.text ?? "").split("\n"));
186
+ const token = context.isError ? "error" : "toolOutput";
187
+ parts.push(
188
+ lines.length === 0
189
+ ? theme.fg("muted", "(empty result)")
190
+ : lines.map((l) => theme.fg(token, l)).join("\n"),
191
+ );
192
+ text.setText(parts.join("\n\n"));
193
+ return text;
194
+ }
195
+
196
+ if (options.isPartial) {
197
+ text.setText(renderStatusLabel("running", toolName, theme));
198
+ return text;
199
+ }
200
+
201
+ text.setText(
202
+ renderStatusLabel(context.isError ? "error" : "success", toolName, theme),
203
+ );
204
+ } catch {
205
+ try {
206
+ text.setText("");
207
+ } catch {
208
+ // keep whatever the component last rendered
209
+ }
210
+ }
211
+ return text;
212
+ }
213
+
214
+ // ── Private helpers ──────────────────────────────────────────────────────────
215
+
216
+ function reuseText(context: RenderContext): Text {
217
+ return (context.lastComponent instanceof Text
218
+ ? context.lastComponent
219
+ : new Text("", 0, 0)) as Text;
220
+ }
221
+
222
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
223
+ return typeof v === "object" && v !== null && !Array.isArray(v);
224
+ }
225
+
226
+ /**
227
+ * Format args as compact JSON, truncated to maxChars.
228
+ * Strings pass through unchanged; other values are JSON.stringify'd.
229
+ * Truncation appends "…".
230
+ */
231
+ function formatArgs(args: unknown, maxChars: number): string {
232
+ try {
233
+ const s =
234
+ typeof args === "string" ? args : JSON.stringify(args, null, 2);
235
+ return s.length > maxChars ? s.slice(0, maxChars) + "…" : s;
236
+ } catch {
237
+ return String(args).slice(0, maxChars);
238
+ }
239
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { jsonSchemaValidator, JsonSchemaType } from "@modelcontextprotocol/sdk/validation";
3
+ import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
4
+ import { TolerantJsonSchemaValidator } from "./schema-validator.js";
5
+
6
+ // Minimal shape of the schema Stitch ships for `upload_design_md.outputSchema`:
7
+ // `variantScreenInstance` self-references the enclosing type via `$defs`, but
8
+ // `$defs` is never emitted at the root. Raw Ajv throws exactly
9
+ // "can't resolve reference #/$defs/ScreenInstance from id #" on this.
10
+ const BROKEN_SCREEN_INSTANCE = {
11
+ description: "An instance of a screen on the project.",
12
+ type: "object",
13
+ properties: {
14
+ label: { description: "Optional. The screen label.", type: "string" },
15
+ variantScreenInstance: {
16
+ $ref: "#/$defs/ScreenInstance",
17
+ description: "Optional. The variant Screen Instance.",
18
+ },
19
+ },
20
+ } as JsonSchemaType;
21
+
22
+ const VALID_SIMPLE = {
23
+ type: "object",
24
+ properties: { ok: { type: "boolean" } },
25
+ } as JsonSchemaType;
26
+
27
+ const okPass = (returnThis: unknown) => ({
28
+ getValidator: () => returnThis,
29
+ }) as unknown as jsonSchemaValidator;
30
+
31
+ describe("TolerantJsonSchemaValidator", () => {
32
+ it("delegates to the inner validator when compilation succeeds", () => {
33
+ const marker = (input: unknown) => ({ valid: true as const, data: input, errorMessage: undefined as undefined });
34
+ const wrapper = new TolerantJsonSchemaValidator(okPass(marker));
35
+ expect(wrapper.getValidator(VALID_SIMPLE)).toBe(marker);
36
+ });
37
+
38
+ it("delegates to a working inner Ajv validator by default", () => {
39
+ const wrapper = new TolerantJsonSchemaValidator();
40
+ const v = wrapper.getValidator(VALID_SIMPLE);
41
+ expect(v({ ok: true })).toMatchObject({ valid: true });
42
+ });
43
+
44
+ it("passes data through untouched when the inner compile throws", () => {
45
+ const raw = new AjvJsonSchemaValidator();
46
+ expect(() => raw.getValidator(BROKEN_SCREEN_INSTANCE)).toThrow(/can't resolve reference #\/\$defs\/ScreenInstance/);
47
+
48
+ const wrapper = new TolerantJsonSchemaValidator();
49
+ const v = wrapper.getValidator(BROKEN_SCREEN_INSTANCE);
50
+ expect(v({ anything: "goes" })).toEqual({
51
+ valid: true,
52
+ data: { anything: "goes" },
53
+ errorMessage: undefined,
54
+ });
55
+ });
56
+ });
@@ -0,0 +1,42 @@
1
+ /** Tolerant JSON Schema validator provider for the MCP SDK client. */
2
+
3
+ import type {
4
+ JsonSchemaType,
5
+ JsonSchemaValidator,
6
+ jsonSchemaValidator,
7
+ } from "@modelcontextprotocol/sdk/validation";
8
+ import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
9
+
10
+ /**
11
+ * Wrapper around the SDK's Ajv-based `jsonSchemaValidator` that survives
12
+ * unsound server-supplied JSON schemas.
13
+ *
14
+ * The SDK pre-compiles every tool's `outputSchema` during `tools/list`
15
+ * (`Client#_cachedToolOutputValidators`), and a schema with an
16
+ * unresolvable `$ref` throws at compile time — rejecting the whole tool
17
+ * list and making all of that server's tools unusable.
18
+ *
19
+ * Known offender: Google's Stitch MCP (`upload_design_md.outputSchema`
20
+ * declares `properties.variantScreenInstance.$ref = "#/$defs/ScreenInstance"`
21
+ * but ships no `$defs` at the root of the schema).
22
+ *
23
+ * A schema that fails to compile degrades to a pass-through validator
24
+ * (structured output data is not validated) instead of taking down the
25
+ * server. Skipped silently — the schema is third-party and the same
26
+ * one would warn again on every reconnect.
27
+ */
28
+ export class TolerantJsonSchemaValidator implements jsonSchemaValidator {
29
+ private readonly _inner: jsonSchemaValidator;
30
+
31
+ constructor(inner?: jsonSchemaValidator) {
32
+ this._inner = inner ?? new AjvJsonSchemaValidator();
33
+ }
34
+
35
+ getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
36
+ try {
37
+ return this._inner.getValidator<T>(schema);
38
+ } catch {
39
+ return (input: unknown) => ({ valid: true, data: input as T, errorMessage: undefined });
40
+ }
41
+ }
42
+ }