diff --git a/doc/connections/GOOGLE-WORKSPACE.md b/doc/connections/GOOGLE-WORKSPACE.md index d22b61209a1..b2ddb847f74 100644 --- a/doc/connections/GOOGLE-WORKSPACE.md +++ b/doc/connections/GOOGLE-WORKSPACE.md @@ -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 diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index aa7ed1cab4a..2b23a3e25ac 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -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")!; diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index b27a0af07bf..0968d92d001 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -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[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.", );