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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ coverage
.DS_Store
.env
tsconfig.tsbuildinfo
dist
.firebaserc
.firebase
.vscode
Expand Down
92 changes: 72 additions & 20 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"validate:release": "bun scripts/validate-release.ts"
},
"devDependencies": {
"@ai-sdk/google": "^3.0.43",
"@types/bun": "^1.3.10",
"@types/react": "^18.3.0",
"firebase-tools": "^14.26.0",
Expand All @@ -39,4 +40,4 @@
"typescript": "^5.5.0"
},
"packageManager": "bun@1.3.1"
}
}
101 changes: 85 additions & 16 deletions packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { CallToolResult, CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js";
import {
StitchConfigSchema,
StitchConfig,
Expand Down Expand Up @@ -77,10 +78,21 @@ export class StitchToolClient implements StitchToolClientSpec {
return headers;
}

private parseToolResponse<T>(result: any, name: string): T {
private asCallToolResult(result: CompatibilityCallToolResult): CallToolResult {
if (Array.isArray((result as CallToolResult).content)) {
return result as CallToolResult;
}
// Legacy protocol format — wrap toolResult as text content
const legacy = result as { toolResult: unknown };
return {
content: [{ type: "text", text: JSON.stringify(legacy.toolResult ?? null) }],
};
}

private parseToolResponse<T>(result: CallToolResult, name: string): T {
if (result.isError) {
const errorText = (result.content as any[])
.map((c: any) => (c.type === "text" ? c.text : ""))
const errorText = result.content
.map((c) => (c.type === "text" ? c.text : ""))
.join("");

let code: StitchErrorCode = "UNKNOWN_ERROR";
Expand Down Expand Up @@ -118,12 +130,9 @@ export class StitchToolClient implements StitchToolClientSpec {
}

// Stitch specific parsing: Check structuredContent first, then JSON in text
const anyResult = result as any;
if (anyResult.structuredContent) return anyResult.structuredContent as T;
if (result.structuredContent) return result.structuredContent as T;

const textContent = (result.content as any[]).find(
(c: any) => c.type === "text",
);
const textContent = result.content.find((c) => c.type === "text");
if (textContent && textContent.type === "text") {
try {
return JSON.parse(textContent.text) as T;
Expand All @@ -132,7 +141,7 @@ export class StitchToolClient implements StitchToolClientSpec {
}
}

return anyResult as T;
return result as unknown as T;
}

async connect() {
Expand All @@ -148,6 +157,11 @@ export class StitchToolClient implements StitchToolClientSpec {
}

private async doConnect() {
// Close existing transport before creating a new one to prevent resource leaks
if (this.transport) {
await this.transport.close().catch(() => {});
}

// Create transport with auth headers injected per-instance (no global fetch mutation)
this.transport = new StreamableHTTPClientTransport(
new URL(this.config.baseUrl),
Expand All @@ -167,19 +181,74 @@ export class StitchToolClient implements StitchToolClientSpec {
this.isConnected = true;
}

/**
* Tools that are safe to retry on network errors (idempotent read operations).
* Unknown tools default to NOT retrying — safer than the reverse.
*/
private static readonly RETRYABLE_TOOLS = new Set([
"list_projects",
"get_project",
"list_screens",
"get_screen",
]);

/**
* Check if an error is a transient network failure (not an application error).
* Uses message substring matching — fragile across Node/Bun versions,
* but no reliable error codes exist for these transport-level failures.
*/
private isNetworkError(error: unknown): boolean {
if (error instanceof StitchError) return false;
const msg =
error instanceof Error ? error.message.toLowerCase() : String(error);
return (
msg.includes("fetch failed") ||
msg.includes("econnrefused") ||
msg.includes("econnreset") ||
msg.includes("etimedout") ||
msg.includes("socket hang up") ||
msg.includes("other side closed")
);
}

/**
* Generic tool caller with type support and error parsing.
* Retries once on transient network errors for idempotent (read) operations.
* Non-idempotent tools (generate, edit, create) are not retried.
*/
async callTool<T>(name: string, args: Record<string, any>): Promise<T> {
async callTool<T>(name: string, args: Record<string, unknown>): Promise<T> {
if (!this.isConnected) await this.connect();

const result = await this.client.callTool(
{ name, arguments: args },
undefined,
{ timeout: this.config.timeout },
);
try {
const result = await this.client.callTool(
{ name, arguments: args },
undefined,
{ timeout: this.config.timeout },
);
return this.parseToolResponse<T>(this.asCallToolResult(result), name);
} catch (error) {
if (
!this.isNetworkError(error) ||
!StitchToolClient.RETRYABLE_TOOLS.has(name)
) {
throw error;
}

// Reconnect and retry once for idempotent operations
this.isConnected = false;
await this.connect();

return this.parseToolResponse<T>(result, name);
try {
const result = await this.client.callTool(
{ name, arguments: args },
undefined,
{ timeout: this.config.timeout },
);
return this.parseToolResponse<T>(this.asCallToolResult(result), name);
} catch (_retryError: unknown) {
throw error; // throw the original error, not the retry error
}
}
}

async listTools() {
Expand Down
Loading