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
47 changes: 47 additions & 0 deletions packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,26 @@ import { SDK_VERSION } from "./version.js";
import { repairToolSchemas } from "./schema-repair.js";
import { EntityManager } from "./entity-manager.js";

function isTransientNetworkError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
const cause = (err as Error & { cause?: unknown }).cause;
const causeMsg =
cause instanceof Error
? cause.message.toLowerCase()
: typeof cause === "string"
? cause.toLowerCase()
: "";
const combined = `${msg} ${causeMsg}`;
return (
combined.includes("fetch failed") ||
combined.includes("econnreset") ||
combined.includes("socket") ||
combined.includes("other side closed") ||
combined.includes("network")
);
}

/**
* Authenticated tool pipe for the Stitch MCP Server.
*
Expand Down Expand Up @@ -178,8 +198,22 @@ export class StitchToolClient implements StitchToolClientSpec {

/**
* Generic tool caller with type support and error parsing.
* Retries once on transient network errors after reconnecting.
*/
async callTool<T>(name: string, args: Record<string, any>): Promise<T> {
try {
return await this.callToolOnce<T>(name, args);
} catch (err) {
if (!isTransientNetworkError(err)) throw err;
await this.resetConnection();
return await this.callToolOnce<T>(name, args);
}
}

private async callToolOnce<T>(
name: string,
args: Record<string, any>,
): Promise<T> {
if (!this.isConnected) await this.connect();

const localTool = this.localVirtualTools.find((t) => t.name === name);
Expand All @@ -196,6 +230,19 @@ export class StitchToolClient implements StitchToolClientSpec {
return this.parseToolResponse<T>(result, name);
}

private async resetConnection(): Promise<void> {
this.isConnected = false;
this.connectPromise = null;
if (this.transport) {
try {
await this.transport.close();
} catch {
/* ignore close errors during reset */
}
this.transport = null;
}
}

/**
* Make a direct REST POST to the Stitch API.
*
Expand Down
44 changes: 44 additions & 0 deletions packages/sdk/test/unit/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,50 @@ describe("StitchToolClient", () => {
});
});

describe("callTool transient network retry", () => {
it("reconnects and retries once on fetch failed", async () => {
const client = new StitchToolClient({ apiKey: "k" });
let attempts = 0;

client["client"].connect = vi.fn(async () => {
client["isConnected"] = true;
});

client["client"].callTool = vi.fn(async () => {
attempts++;
if (attempts === 1) {
const err = new TypeError("fetch failed");
(err as any).cause = new Error("other side closed");
throw err;
}
return {
isError: false,
content: [{ type: "text", text: '{"ok":true}' }],
};
});

const result = await client.callTool("list_projects", {});
expect(result).toEqual({ ok: true });
expect(attempts).toBe(2);
});

it("does not retry on auth errors", async () => {
const client = new StitchToolClient({ apiKey: "bad" });
client["isConnected"] = true;

client["client"].callTool = vi.fn(async () => ({
isError: true,
content: [{ type: "text", text: "401 Unauthorized" }],
}));

const { StitchError } = await import("../../src/spec/errors.js");
await expect(client.callTool("list_projects", {})).rejects.toThrow(
StitchError,
);
expect(client["client"].callTool).toHaveBeenCalledTimes(1);
});
});

// ─── Slice 3: httpPost transport ────────────────────────────────
describe("httpPost", () => {
// Test 10: sends X-Goog-Api-Key header
Expand Down