fix(connections): isolate Google tool call failures

Return and audit per-service errors without marking unrelated Google services unhealthy. Keep shared credential failures and per-call consent checks authoritative.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-08 06:51:39 -05:00
1 parent 8fa68283ba
commit b6b5341e0a
3 files changed
+26 -11

No files matched your search

+2
View File
@@ -29,6 +29,8 @@ the catalog is shared, actions skipped because of narrower consent are preserved
when another active grant supports them. Failed services and tools removed from a
successful discovery are still disabled. Each caller's listing and dispatch use only their
selected grant, without combining permissions from different identities.
Individual call failures are reported and audited without marking unrelated
Google services unhealthy; they do not trigger automatic write retries.
Existing individual product connections and their direct setup routes remain
supported. They are not automatically merged, upgraded or reauthorized. The Sheets
@@ -8256,6 +8256,18 @@ describeEmbeddedPostgres("tool access service", () => {
await expect(fixture.service.refreshCatalog(fixture.connected.connectionId, fixture.actor)).rejects.toMatchObject({ details: { code: "google_workspace_services_unavailable" } });
});
it("does not let a cached Gmail call failure hide healthy Docs actions", async () => {
const fixture = await connectWorkspace("customer");
const gmail = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((tool) => tool.upstreamToolName === "gmail__get_message")!;
fixture.unavailable.add("gmailmcp.googleapis.com");
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: gmail.name, parameters: {} })).rejects.toMatchObject({ reasonCode: "mcp_remote_status" });
expect((await fixture.service.getConnection(fixture.connected.connectionId)).healthStatus).toBe("ok");
const doc = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((tool) => tool.upstreamToolName === "docs__read_doc")!;
expect(doc).toBeTruthy();
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: doc.name, parameters: {} })).resolves.toMatchObject({ status: "completed" });
expect(fixture.calls).toEqual(["read_doc"]);
});
it.each(["managed", "customer"] as const)("blocks the auth retry and updates listing after %s refresh narrows consent", async (mode) => {
const fixture = await connectWorkspace(mode);
const tool = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((item) => item.upstreamToolName === "docs__read_doc")!;
+12 -11
View File
@@ -5942,6 +5942,11 @@ export function createToolGatewayService(
}
const workspaceTarget = connection.config.sourceTemplateKey === "google-workspace"
? googleWorkspaceToolTarget(entry.toolName) : null;
// One service's call failure is not evidence that every Google endpoint is
// unhealthy. The invocation still records/returns its error; catalog refresh
// handles service availability, and grant checks remain authoritative.
const markCallHealth = (status: Parameters<typeof markRemoteConnectionHealth>[1], message: string) =>
workspaceTarget && status !== "ok" ? Promise.resolve() : markRemoteConnectionHealth(connection, status, message);
if (connection.config.sourceTemplateKey === "google-workspace"
&& (!workspaceTarget || !isGoogleWorkspaceToolGranted(entry.toolName, googleWorkspaceGrantedScopes(grant)))) {
throw new ToolGatewayHttpError(403, "This Google action needs a permission you have not granted. Reconnect Google Workspace to change access.", "oauth_insufficient_scope");
@@ -6075,7 +6080,7 @@ export function createToolGatewayService(
let response = await dispatchRemote(endpoint, requestInit);
if (isInsufficientConnectionScope(response)) {
await response.body?.cancel().catch(() => undefined);
await markRemoteConnectionHealth(connection, "degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE);
await markCallHealth("degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE);
throw new ToolGatewayHttpError(403, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, "oauth_insufficient_scope", { connectionId: connection.id });
}
const oauth = asRecord(asRecord(connection.config)?.oauth);
@@ -6220,7 +6225,7 @@ export function createToolGatewayService(
response.headers.get("traceparent"),
};
if (isInsufficientConnectionScope(response, body)) {
await markRemoteConnectionHealth(connection, "degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE);
await markCallHealth("degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE);
throw new ToolGatewayHttpError(403, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, "oauth_insufficient_scope", {
connectionId: connection.id, catalogEntryId: entry.id,
});
@@ -6229,7 +6234,7 @@ export function createToolGatewayService(
// Session expiration is recoverable on an explicit retry. Marking the
// connection unhealthy here would hide every tool and prevent it.
if (!sessionExpired) {
await markRemoteConnectionHealth(connection, "error", "Remote MCP server returned an HTTP error.");
await markCallHealth("error", "Remote MCP server returned an HTTP error.");
}
throw new ToolGatewayHttpError(
502,
@@ -6277,8 +6282,7 @@ export function createToolGatewayService(
}
if (payloadRecord.error !== undefined) {
const errorRecord = asRecord(payloadRecord.error);
await markRemoteConnectionHealth(
connection,
await markCallHealth(
"error",
"Remote MCP server returned a JSON-RPC error.",
);
@@ -6319,8 +6323,7 @@ export function createToolGatewayService(
false,
sourceTemplateKey,
);
await markRemoteConnectionHealth(
connection,
await markCallHealth(
"ok",
"Remote MCP server responded to tools/call.",
);
@@ -6355,8 +6358,7 @@ export function createToolGatewayService(
);
}
if (error instanceof Error && error.name === "AbortError") {
await markRemoteConnectionHealth(
connection,
await markCallHealth(
"error",
"Remote MCP tool call timed out.",
);
@@ -6371,8 +6373,7 @@ export function createToolGatewayService(
},
);
}
await markRemoteConnectionHealth(
connection,
await markCallHealth(
"error",
"Remote MCP tool call failed.",
);