Skip to content

Commit c301bd3

Browse files
authored
refactor(proxy,adapters): unify MCP proxy onto StitchToolClient and isolate tool catalog subpath (#371)
1 parent aedd351 commit c301bd3

15 files changed

Lines changed: 711 additions & 399 deletions

‎packages/sdk/src/adk-adapter.ts‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,24 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
import { FunctionTool } from "@google/adk";
15+
import type { FunctionTool as FunctionToolType } from "@google/adk";
1616
import type { Schema } from "@google/genai";
1717
import { toolDefinitions } from "../generated/src/tool-definitions.js";
1818
import { getOrCreateClient } from "./singleton.js";
1919

20+
// @google/adk is an OPTIONAL peer dependency: it must not be required to
21+
// install or import the core SDK. Guarded dynamic import gives consumers an
22+
// actionable error instead of a bare ERR_MODULE_NOT_FOUND.
23+
let FunctionTool: typeof FunctionToolType;
24+
try {
25+
({ FunctionTool } = await import("@google/adk"));
26+
} catch {
27+
throw new Error(
28+
`"@google/stitch-sdk/adk" requires the optional peer dependency "@google/adk", ` +
29+
`which is not installed. Install it with:\n\n npm install @google/adk\n`,
30+
);
31+
}
32+
2033
/**
2134
* Recursively cleans and flattens a JSON Schema to make it compatible with the Google ADK/Gemini API.
2235
* It resolves internal `#/$defs/` references directly into the object, and removes
@@ -29,6 +42,11 @@ import { getOrCreateClient } from "./singleton.js";
2942
function cleanSchema(schema: any): any {
3043
if (!schema || typeof schema !== "object") return schema;
3144
const defs = schema.$defs || {};
45+
// Defs currently being resolved. A self-recursive $def cannot be
46+
// expressed in the Gemini schema dialect — break the cycle with {}
47+
// instead of embedding an in-progress object by reference (which
48+
// produced circular output that exploded on JSON serialization).
49+
const inProgress = new Set<string>();
3250

3351
function stripAndResolve(node: any, seen = new Map()): any {
3452
if (!node || typeof node !== "object") return node;
@@ -50,7 +68,12 @@ function cleanSchema(schema: any): any {
5068
) {
5169
const defName = node.$ref.replace("#/$defs/", "");
5270
if (defs[defName]) {
71+
if (inProgress.has(defName)) {
72+
return {}; // recursive $def — cycle broken
73+
}
74+
inProgress.add(defName);
5375
const target = stripAndResolve(defs[defName], seen);
76+
inProgress.delete(defName);
5477
const resolved = { ...target };
5578
for (const [k, v] of Object.entries(node)) {
5679
if (
@@ -108,7 +131,20 @@ function cleanSchema(schema: any): any {
108131
export function stitchAdkTools(options?: {
109132
apiKey?: string;
110133
include?: string[];
111-
}): FunctionTool<Schema>[] {
134+
}): FunctionToolType<Schema>[] {
135+
// A misspelled tool name silently vanishing from an agent's toolbox
136+
// is undebuggable — validate loudly [V1_PLAN §3.6].
137+
if (options?.include) {
138+
const known = new Set(toolDefinitions.map((t) => t.name));
139+
const unknown = options.include.filter((name) => !known.has(name));
140+
if (unknown.length > 0) {
141+
throw new Error(
142+
`Unknown Stitch tool name(s) in include filter: ${unknown.join(", ")}.\n` +
143+
`Available tools: ${[...known].join(", ")}`,
144+
);
145+
}
146+
}
147+
112148
const client = getOrCreateClient(options);
113149

114150
const filtered = options?.include

‎packages/sdk/src/proxy/client.ts‎

Lines changed: 19 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -13,100 +13,34 @@
1313
// limitations under the License.
1414

1515
import { StitchProxyConfig } from "../spec/proxy.js";
16-
import { buildAuthHeaders } from "../auth.js";
16+
import type { StitchToolClient } from "../client.js";
1717
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
18-
import { repairToolSchemas } from "../schema-repair.js";
1918

2019
/**
2120
* Shared state for proxy handlers.
21+
*
22+
* `client` is the ONE MCP stack in the repo (D9): a real StitchToolClient
23+
* over the MCP SDK's StreamableHTTPClientTransport. The caller constructs
24+
* and injects it (see core.ts) — this module never builds transports or
25+
* speaks JSON-RPC itself.
2226
*/
2327
export interface ProxyContext {
2428
config: StitchProxyConfig;
29+
client: StitchToolClient;
2530
remoteTools: Tool[];
2631
}
2732

2833
/**
29-
* Forward a JSON-RPC request to Stitch.
30-
*/
31-
export async function forwardToStitch(
32-
config: StitchProxyConfig,
33-
method: string,
34-
params?: unknown,
35-
): Promise<unknown> {
36-
const request = {
37-
jsonrpc: "2.0",
38-
method,
39-
params: params ?? {},
40-
id: Date.now(),
41-
};
42-
43-
let response: Response;
44-
try {
45-
response = await fetch(config.url, {
46-
method: "POST",
47-
headers: {
48-
"Content-Type": "application/json",
49-
Accept: "application/json",
50-
...buildAuthHeaders(config),
51-
},
52-
body: JSON.stringify(request),
53-
});
54-
} catch (err: any) {
55-
throw new Error(`Network failure connecting to Stitch API: ${err.message}`);
56-
}
57-
58-
if (!response.ok) {
59-
const errorText = await response.text();
60-
throw new Error(`Stitch API error (${response.status}): ${errorText}`);
61-
}
62-
63-
const result = (await response.json()) as {
64-
error?: { message: string };
65-
result?: unknown;
66-
};
67-
68-
if (result.error) {
69-
throw new Error(`Stitch RPC error: ${result.error.message}`);
70-
}
71-
72-
return result.result;
73-
}
74-
75-
/**
76-
* Initialize connection to Stitch and fetch tools.
34+
* Initialize the upstream Stitch connection and fetch tools.
35+
*
36+
* The MCP SDK handles the initialize handshake (protocol version,
37+
* session id, awaited notifications/initialized) — the hand-rolled
38+
* JSON-RPC machinery that used to live here is gone.
7739
*/
7840
export async function initializeStitchConnection(
7941
ctx: ProxyContext,
8042
): Promise<void> {
81-
// Send initialize request
82-
await forwardToStitch(ctx.config, "initialize", {
83-
protocolVersion: ctx.config.protocolVersion || "2024-11-05",
84-
capabilities: {},
85-
clientInfo: {
86-
name: ctx.config.name,
87-
version: ctx.config.version,
88-
},
89-
});
90-
91-
// Send initialized notification (fire and forget)
92-
fetch(ctx.config.url, {
93-
method: "POST",
94-
headers: {
95-
"Content-Type": "application/json",
96-
Accept: "application/json",
97-
...buildAuthHeaders(ctx.config),
98-
},
99-
body: JSON.stringify({
100-
jsonrpc: "2.0",
101-
method: "notifications/initialized",
102-
}),
103-
}).catch((err) => {
104-
console.error(
105-
"[stitch-proxy] Failed to send initialized notification:",
106-
err,
107-
);
108-
});
109-
43+
await ctx.client.connect();
11044
await refreshTools(ctx);
11145
console.error(
11246
`[stitch-proxy] Connected to Stitch, discovered ${ctx.remoteTools.length} tools`,
@@ -116,15 +50,12 @@ export async function initializeStitchConnection(
11650
/**
11751
* Refresh the cached tools list from Stitch.
11852
*
119-
* Applies schema repair to inject missing $defs before the tools are
120-
* re-served to MCP clients whose AJV validators would otherwise crash
121-
* on unresolved $ref targets.
53+
* Stores schemas RAW, exactly as served. Repair (injecting missing
54+
* $defs) is a SERVING concern applied where schemas are consumed —
55+
* see handlers/listTools.ts — never at capture: the tools-manifest
56+
* is the pipeline's source of truth and must not be coupled to the
57+
* repair heuristics.
12258
*/
12359
export async function refreshTools(ctx: ProxyContext): Promise<void> {
124-
const toolsResult = (await forwardToStitch(ctx.config, "tools/list", {})) as {
125-
tools: Tool[];
126-
};
127-
const tools = toolsResult.tools || [];
128-
repairToolSchemas(tools);
129-
ctx.remoteTools = tools;
60+
ctx.remoteTools = (await ctx.client.listToolsRaw()).tools;
13061
}

‎packages/sdk/src/proxy/core.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,16 @@ import {
2020
StitchProxyConfig,
2121
StitchProxySpec,
2222
} from "../spec/proxy.js";
23+
import { StitchToolClient } from "../client.js";
2324
import { ProxyContext, initializeStitchConnection } from "./client.js";
2425
import { registerListToolsHandler } from "./handlers/listTools.js";
2526
import { registerCallToolHandler } from "./handlers/callTool.js";
2627

2728
/**
2829
* A proxy server that forwards MCP requests to Stitch.
29-
* Bypasses SDK transport layer to handle specific auth and JSON-RPC forwarding.
30+
* Runs on the SAME StitchToolClient / MCP SDK transport as the rest of the
31+
* SDK (D9 — one MCP stack); it does not bypass the transport or speak
32+
* hand-rolled JSON-RPC. It adds Stitch auth + virtual-tool routing on top.
3033
*/
3134
export class StitchProxy implements StitchProxySpec {
3235
private config: StitchProxyConfig;
@@ -41,7 +44,12 @@ export class StitchProxy implements StitchProxySpec {
4144
inputConfig?.quotaProjectId ||
4245
process.env.STITCH_PROJECT_ID ||
4346
process.env.GOOGLE_CLOUD_PROJECT,
44-
url: inputConfig?.url || process.env.STITCH_MCP_URL,
47+
// Precedence: explicit config > STITCH_BASE_URL > STITCH_MCP_URL
48+
// (STITCH_MCP_URL stays accepted for the proxy binary).
49+
url:
50+
inputConfig?.url ||
51+
process.env.STITCH_BASE_URL ||
52+
process.env.STITCH_MCP_URL,
4553
name: inputConfig?.name,
4654
version: inputConfig?.version,
4755
};
@@ -67,9 +75,17 @@ export class StitchProxy implements StitchProxySpec {
6775
},
6876
);
6977

70-
// Shared context for handlers
78+
// Shared context for handlers. The proxy runs on the real
79+
// StitchToolClient (one MCP stack, D9) — map proxy config onto the
80+
// client's config shape.
7181
this.ctx = {
7282
config: this.config,
83+
client: new StitchToolClient({
84+
apiKey: this.config.apiKey,
85+
accessToken: this.config.accessToken,
86+
projectId: this.config.quotaProjectId,
87+
baseUrl: this.config.url,
88+
}),
7389
remoteTools: [] as Tool[],
7490
};
7591

@@ -90,5 +106,6 @@ export class StitchProxy implements StitchProxySpec {
90106

91107
async close(): Promise<void> {
92108
await this.server.close();
109+
await this.ctx.client.close();
93110
}
94111
}

‎packages/sdk/src/proxy/handlers/callTool.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
1616
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
1717
import type { ProxyContext } from "../client.js";
18-
import { forwardToStitch } from "../client.js";
1918
import { isVirtualTool, handleVirtualTool } from "../virtual-tools.js";
2019

2120
/**
@@ -50,10 +49,19 @@ export function registerCallToolHandler(
5049
}
5150

5251
try {
53-
const result = await forwardToStitch(ctx.config, "tools/call", {
54-
name,
55-
arguments: args,
56-
});
52+
// Forward the RAW envelope verbatim — the downstream MCP client
53+
// owns error semantics (isError stays an envelope, never a throw).
54+
const result = await ctx.client.callToolRaw(name, args ?? {});
55+
if (result == null) {
56+
// A handler returning undefined is a protocol violation downstream;
57+
// surface it as a proper MCP error envelope instead.
58+
return {
59+
isError: true,
60+
content: [
61+
{ type: "text", text: `Upstream returned no result for ${name}` },
62+
],
63+
};
64+
}
5765
return result as { content: Array<{ type: string; text: string }> };
5866
} catch (err) {
5967
const errorMessage = err instanceof Error ? err.message : String(err);

‎packages/sdk/src/proxy/handlers/listTools.ts‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
1616
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
1717
import type { ProxyContext } from "../client.js";
1818
import { refreshTools } from "../client.js";
19-
import { downloadAssetsTool } from "../virtual-tools.js";
19+
import { virtualTools } from "../virtual-tools.js";
20+
import { repairToolSchemas } from "../../schema-repair.js";
2021

21-
const PROXY_VIRTUAL_TOOLS = [downloadAssetsTool].map((t) => ({
22+
const PROXY_VIRTUAL_TOOLS = virtualTools.map((t) => ({
2223
name: t.name,
2324
description: t.description,
2425
inputSchema: t.inputSchema,
@@ -44,6 +45,27 @@ export function registerListToolsHandler(
4445
);
4546
}
4647
}
47-
return { tools: [...ctx.remoteTools, ...PROXY_VIRTUAL_TOOLS] };
48+
// ctx.remoteTools holds RAW schemas (capture truth). Repair a copy at
49+
// serving time: downstream MCP clients' AJV validators crash on
50+
// unresolved $ref targets the backend sometimes omits.
51+
const served = structuredClone(ctx.remoteTools);
52+
repairToolSchemas(served);
53+
54+
// Collision detection: if the server ever ships a tool with a virtual
55+
// tool's name, the virtual tool wins the callTool route (isVirtualTool
56+
// is checked first) — so don't serve a duplicate listing, and say so
57+
// loudly instead of silently shadowing.
58+
const virtualNames = new Set(PROXY_VIRTUAL_TOOLS.map((t) => t.name));
59+
const deduped = served.filter((t) => {
60+
if (virtualNames.has(t.name)) {
61+
console.warn(
62+
`[stitch-proxy] Remote tool "${t.name}" collides with a local ` +
63+
`virtual tool and is shadowed. Rename one of them.`,
64+
);
65+
return false;
66+
}
67+
return true;
68+
});
69+
return { tools: [...deduped, ...PROXY_VIRTUAL_TOOLS] };
4870
});
4971
}

‎packages/sdk/src/proxy/index.ts‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,5 @@
1414

1515
// Proxy barrel exports
1616
export { StitchProxy } from "./core.js";
17-
export {
18-
forwardToStitch,
19-
initializeStitchConnection,
20-
refreshTools,
21-
} from "./client.js";
17+
export { initializeStitchConnection, refreshTools } from "./client.js";
2218
export type { ProxyContext } from "./client.js";

0 commit comments

Comments
 (0)