mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 09:37:19 +01:00
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:
1 parent
8fa68283ba
commit
b6b5341e0a
3 files changed
+26
-11
No files matched your search
@@ -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")!;
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user