diff --git a/doc/connections/GOOGLE-WORKSPACE.md b/doc/connections/GOOGLE-WORKSPACE.md index d834be2d949..74187eee435 100644 --- a/doc/connections/GOOGLE-WORKSPACE.md +++ b/doc/connections/GOOGLE-WORKSPACE.md @@ -158,3 +158,33 @@ Preview tools default to disabled. Read profiles expose only reviewed read operations. Write profiles add only the reviewed write operations for their app; destructive or unreviewed tools do not become available merely because Google adds them upstream. + +### Google Chat scope reduction (2026-09-22) + +`chat.read` requests only `chat.spaces.readonly` and `chat.messages.readonly`. +`chat.write` adds `chat.messages.create`. The same sets apply to both managed +and customer-owned OAuth methods. Neither requests `chat.memberships.readonly` +nor `chat.users.readstate.readonly`. + +Conversation lookup, message history, ordinary message search, and the write +profile's message sending remain supported. Membership listing and marking +messages read/unread remain outside the tool allowlist. `search_messages` +cannot filter by read state: the app hides `isUnread` from its agent and Test +schemas and rejects any explicit `isUnread`/`is_unread` argument, including +`false` or `null`, before dispatch. It never silently drops the filter. The +guard also applies to cached catalogs and existing broader grants. + +References: Google's [Chat MCP setup](https://developers.google.com/workspace/chat/api/guides/configure-mcp-server) +and [message-search parameters](https://developers.google.com/workspace/chat/api/reference/mcp/tools_list/search_messages). + +Roll out the app and Cloud broker scope registries together in staging and +production: their signed requests and sealed credentials use exact scope sets. +During a mixed-version rollout, Chat authorization/refresh can fail closed. +Existing Google tokens are not retroactively narrowed or revoked by this code +change. A refresh response still containing removed scopes, or omitting the +scope set needed to verify the grant, is rejected by the broker; reconnect +affected Chat grants for new consent. Do not revoke the +shared Google client to migrate one profile, because that can break other +Workspace connections. After deployment, verify fresh reduced-scope consent, +ordinary Chat search/history and sending, then reconcile Google Console and +the verification evidence with the deployed scope set. diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index b4ae9cd614d..b82d7a0a047 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -181,9 +181,7 @@ const GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS = [ riskTier: "S3", scopes: [ googleScope("chat.spaces.readonly"), - googleScope("chat.memberships.readonly"), googleScope("chat.messages.readonly"), - googleScope("chat.users.readstate.readonly"), ], writeTools: [], }, @@ -195,9 +193,7 @@ const GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS = [ riskTier: "S4", scopes: [ googleScope("chat.spaces.readonly"), - googleScope("chat.memberships.readonly"), googleScope("chat.messages.readonly"), - googleScope("chat.users.readstate.readonly"), googleScope("chat.messages.create"), ], writeTools: ["send_message"], diff --git a/packages/shared/src/app-definitions/google-chat.json b/packages/shared/src/app-definitions/google-chat.json index 3f075f9cde2..f3cf1f37b4b 100644 --- a/packages/shared/src/app-definitions/google-chat.json +++ b/packages/shared/src/app-definitions/google-chat.json @@ -53,12 +53,10 @@ "serverUrl": "https://chatmcp.googleapis.com/mcp/v1", "scopesHint": [ "https://www.googleapis.com/auth/chat.spaces.readonly", - "https://www.googleapis.com/auth/chat.memberships.readonly", - "https://www.googleapis.com/auth/chat.messages.readonly", - "https://www.googleapis.com/auth/chat.users.readstate.readonly" + "https://www.googleapis.com/auth/chat.messages.readonly" ] }, - "guidanceMd": "Connect Google Chat to search conversations and read messages.", + "guidanceMd": "Connect Google Chat to search conversations and read messages. Read/unread filtering and membership listing are not supported.", "warnings": [ "Google Workspace MCP servers are in Developer Preview." ], @@ -89,16 +87,14 @@ "metadataUrl": "https://accounts.google.com/.well-known/openid-configuration", "scopesHint": [ "https://www.googleapis.com/auth/chat.spaces.readonly", - "https://www.googleapis.com/auth/chat.memberships.readonly", - "https://www.googleapis.com/auth/chat.messages.readonly", - "https://www.googleapis.com/auth/chat.users.readstate.readonly" + "https://www.googleapis.com/auth/chat.messages.readonly" ], "oauthAuthorizationParams": { "access_type": "offline", "prompt": "consent" } }, - "guidanceMd": "Enable the Chat and Chat MCP APIs, configure a Chat app, then register Paperclip's callback URI.", + "guidanceMd": "Enable the Chat and Chat MCP APIs, configure a Chat app, then register Paperclip's callback URI. Read/unread filtering and membership listing are not supported.", "consoleLinks": { "register": "https://console.cloud.google.com/auth/clients", "docs": "https://developers.google.com/workspace/guides/configure-mcp-servers" @@ -133,13 +129,11 @@ "serverUrl": "https://chatmcp.googleapis.com/mcp/v1", "scopesHint": [ "https://www.googleapis.com/auth/chat.spaces.readonly", - "https://www.googleapis.com/auth/chat.memberships.readonly", "https://www.googleapis.com/auth/chat.messages.readonly", - "https://www.googleapis.com/auth/chat.users.readstate.readonly", "https://www.googleapis.com/auth/chat.messages.create" ] }, - "guidanceMd": "Connect Google Chat to read conversations and send approved messages.", + "guidanceMd": "Connect Google Chat to read conversations and send approved messages. Read/unread filtering and membership listing are not supported.", "warnings": [ "Sending messages requires approval.", "Google Workspace MCP servers are in Developer Preview." @@ -171,9 +165,7 @@ "metadataUrl": "https://accounts.google.com/.well-known/openid-configuration", "scopesHint": [ "https://www.googleapis.com/auth/chat.spaces.readonly", - "https://www.googleapis.com/auth/chat.memberships.readonly", "https://www.googleapis.com/auth/chat.messages.readonly", - "https://www.googleapis.com/auth/chat.users.readstate.readonly", "https://www.googleapis.com/auth/chat.messages.create" ], "oauthAuthorizationParams": { @@ -181,7 +173,7 @@ "prompt": "consent" } }, - "guidanceMd": "Enable the Chat and Chat MCP APIs, configure a Chat app, then register Paperclip's callback URI.", + "guidanceMd": "Enable the Chat and Chat MCP APIs, configure a Chat app, then register Paperclip's callback URI. Read/unread filtering and membership listing are not supported.", "consoleLinks": { "register": "https://console.cloud.google.com/auth/clients", "docs": "https://developers.google.com/workspace/guides/configure-mcp-servers" diff --git a/packages/shared/src/google-workspace-connectors.ts b/packages/shared/src/google-workspace-connectors.ts index ed4645eb428..7f0d42b496c 100644 --- a/packages/shared/src/google-workspace-connectors.ts +++ b/packages/shared/src/google-workspace-connectors.ts @@ -26,8 +26,8 @@ export const GOOGLE_WORKSPACE_CONNECTOR_PROFILES: Readonly { ); }); + it.each(["read", "write"])("requests only reduced Chat scopes for customer-owned %s OAuth, including reconnect", async (capability) => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const actor = { actorType: "user" as const, actorId: "board" }; + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "google-chat", + connectionMethodKey: `customer-${capability}-oauth`, + name: "Chat scope fixture", + grantKind: "user", + oauthClient: { clientId: "google-chat-client", clientSecret: "google-chat-secret" }, + }, actor); + const scopes = ["chat.spaces.readonly", "chat.messages.readonly", ...(capability === "write" ? ["chat.messages.create"] : [])] + .map((scope) => `https://www.googleapis.com/auth/${scope}`); + const removed = ["chat.memberships.readonly", "chat.users.readstate.readonly"] + .map((scope) => `https://www.googleapis.com/auth/${scope}`); + const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId)); + // A saved broad scope hint must not leak back into the next consent URL. + await db.update(toolConnections).set({ config: { + ...connection.config, + oauth: { ...(connection.config.oauth as Record), scopes: [...scopes, ...removed] }, + } }).where(eq(toolConnections.id, connection.id)); + const input = { redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor }; + const started = await service.startOAuth(company.id, connection.id, input); + expect(new URL(started.authorizationUrl).searchParams.get("scope")?.split(" ")).toEqual(scopes); + for (const removedScope of removed) { + await expect(service.startOAuth(company.id, connection.id, { ...input, scopes: [...scopes, removedScope] })) + .rejects.toMatchObject({ status: 400, details: { code: "oauth_scope_widening_rejected", scopes: [removedScope] } }); + } + }); + it("keeps tools outside a Google Workspace capability profile disabled", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); diff --git a/server/src/__tests__/tool-gateway-service.test.ts b/server/src/__tests__/tool-gateway-service.test.ts index 6d8420561f6..99c592d9890 100644 --- a/server/src/__tests__/tool-gateway-service.test.ts +++ b/server/src/__tests__/tool-gateway-service.test.ts @@ -38,6 +38,7 @@ import { commitToolActionReview } from "../services/tool-action-review.js"; import { materializeNativeInteractionResponses } from "../services/native-runtime/native-interaction-bridge.js"; import { toolActionDeliveryService } from "../services/tool-action-delivery.js"; import { secretService } from "../services/secrets.js"; +import { toolAccessService } from "../services/tool-access.js"; import { createToolGatewayService, ToolGatewayHttpError, @@ -1314,6 +1315,68 @@ describeEmbeddedPostgres("tool gateway service", () => { ]); }); + it("hides cached Chat unread filters and blocks agent and board requests before provider dispatch", async () => { + const { company, agent, run } = await createRunFixture(db); + const { connection, catalogEntry } = await createRemoteMcpToolFixture(db, company.id); + await db.update(toolConnections).set({ + lastCatalogRefreshAt: new Date(), + config: { + url: "https://8.8.8.8/mcp", + sourceTemplateKey: "google-chat", + connectionMethodKey: "customer-read-oauth", + oauth: { scopes: ["https://www.googleapis.com/auth/chat.users.readstate.readonly"] }, + }, + }).where(eq(toolConnections.id, connection.id)); + await db.update(toolCatalogEntries).set({ + name: "search_messages", toolName: "search_messages", description: "Search unread messages.", + inputSchema: { type: "object", properties: { + searchParameters: { type: "object", properties: { + isUnread: { type: "boolean" }, keywords: { type: "array", items: { type: "string" } }, + } }, + } }, + }).where(eq(toolCatalogEntries.id, catalogEntry.id)); + await db.insert(toolPolicies).values({ + companyId: company.id, name: "Allow Chat reads", policyType: "allow", selectors: { riskLevel: "read" }, + }); + const provider = vi.fn(async (_url, init) => { + const payload = JSON.parse(String(init.body)); + if (payload.method === "notifications/initialized") return new Response(null, { status: 202 }); + return Response.json({ jsonrpc: "2.0", id: payload.id, result: payload.method === "initialize" + ? { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "chat-fixture", version: "1" } } + : { content: [{ type: "text", text: "Found matching messages." }] } }); + }); + const gateway = createTestToolGatewayService(db, { remoteHttpRequest: provider }); + const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const tool = (await gateway.listToolsForSession(session.token)) + .find((candidate) => candidate.upstreamToolName === "search_messages")!; + expect(tool).toBeTruthy(); + expect(JSON.stringify(tool.parametersSchema)).not.toContain("isUnread"); + expect(tool.description).toContain("Read/unread filtering is not supported."); + const catalog = await toolAccessService(db).listCatalog(connection.id, company.id); + expect(JSON.stringify(catalog[0].inputSchema)).not.toContain("isUnread"); + expect(catalog[0].description).toContain("Read/unread filtering is not supported."); + + for (const isUnread of [true, false]) { + const parameters = { searchParameters: { isUnread, keywords: ["release"] } }; + const error = { status: 400, details: { code: "google_chat_unread_filter_unsupported" } }; + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters })) + .rejects.toMatchObject(error); + await expect(gateway.executeTestCall({ + companyId: company.id, connectionId: connection.id, agentId: agent.id, + userId: "board", toolName: "search_messages", parameters, + })).rejects.toMatchObject(error); + } + expect(provider).not.toHaveBeenCalled(); + + const parameters = { searchParameters: { keywords: ["release"], startTime: "2026-09-22T00:00:00Z" }, pageSize: 10 }; + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters })) + .resolves.toMatchObject({ status: "completed" }); + const calls = provider.mock.calls.map(([, init]) => JSON.parse(String(init.body))) + .filter((payload) => payload.method === "tools/call"); + expect(calls).toHaveLength(1); + expect(calls[0].params).toEqual({ name: "search_messages", arguments: parameters }); + }); + it("explains Google Workspace preview enrollment when a tool call is denied", async () => { const { company, agent, run } = await createRunFixture(db); const { connection } = await createRemoteMcpToolFixture(db, company.id); diff --git a/server/src/services/gmail-tool-governance.test.ts b/server/src/services/gmail-tool-governance.test.ts index 890332546ec..526f7b5b516 100644 --- a/server/src/services/gmail-tool-governance.test.ts +++ b/server/src/services/gmail-tool-governance.test.ts @@ -37,4 +37,17 @@ describe("Google Workspace tool governance", () => { expect(isGoogleWorkspaceToolAllowed("sheets.write", { name: "sheets.updateValues" })).toBe(true); expect(isGoogleWorkspaceToolAllowed("people.read", { name: "delete_contact" })).toBe(false); }); + + it("keeps Chat search, history, and sending but excludes membership and read-state tools", () => { + for (const profile of ["chat.read", "chat.write"] as const) { + for (const name of ["search_conversations", "list_messages", "search_messages"]) { + expect(isGoogleWorkspaceToolAllowed(profile, { name })).toBe(true); + } + for (const name of ["list_memberships", "mark_as_read", "mark_as_unread"]) { + expect(isGoogleWorkspaceToolAllowed(profile, { name })).toBe(false); + } + } + expect(isGoogleWorkspaceToolAllowed("chat.read", { name: "send_message" })).toBe(false); + expect(isGoogleWorkspaceToolAllowed("chat.write", { name: "send_message" })).toBe(true); + }); }); diff --git a/server/src/services/google-chat-tool-policy.test.ts b/server/src/services/google-chat-tool-policy.test.ts new file mode 100644 index 00000000000..827c1eff12a --- /dev/null +++ b/server/src/services/google-chat-tool-policy.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + assertGoogleChatToolArgumentsSupported, + googleChatToolDescription, + googleChatToolInputSchema, +} from "./google-chat-tool-policy.js"; + +const chat = { config: { sourceTemplateKey: "google-chat" } }; + +describe("Google Chat search without read-state permission", () => { + it.each([true, false, null, "true"])("rejects explicit unread filters (%s)", (isUnread) => { + for (const tool of ["search_messages", "chat.searchMessages", "google/chat/search-messages"]) { + for (const args of [ + { searchParameters: { isUnread } }, + { searchParameters: { is_unread: isUnread } }, + { search_parameters: { isUnread } }, + { search_parameters: { is_unread: isUnread } }, + { isUnread }, + ]) { + expect(() => assertGoogleChatToolArgumentsSupported(chat, tool, args)).toThrow( + expect.objectContaining({ status: 400, details: { code: "google_chat_unread_filter_unsupported" } }), + ); + } + } + }); + + it("covers legacy profile-bound and URL-only connections without inspecting granted scopes", () => { + for (const config of [ + { oauth: { connectorProfile: "chat.read" } }, + { oauth: { connectorProfile: "chat.write" } }, + { url: "https://chatmcp.googleapis.com/mcp/v1" }, + ]) { + expect(() => assertGoogleChatToolArgumentsSupported({ config }, "search_messages", { + searchParameters: { isUnread: true }, + })).toThrow("read/unread filtering is not supported"); + } + }); + + it("rejects encoded filters instead of forwarding an invalid searchParameters value", () => { + for (const searchParameters of ['{"isUnread":true}', "is:unread", [], null, true]) { + expect(() => assertGoogleChatToolArgumentsSupported(chat, "search_messages", { searchParameters })) + .toThrow("searchParameters must be an object"); + } + }); + + it("preserves supported search filters and unrelated providers/tools", () => { + const args = { searchParameters: { + keywords: ["release", "unread"], conversationId: "spaces/example", sender: "users/me", + startTime: "2026-09-22T00:00:00Z", hasLink: true, mentionsMe: true, + conversationIncludesUser: "users/example", spaceDisplayNames: ["Engineering"], + }, pageSize: 10, pageToken: "next" }; + expect(() => assertGoogleChatToolArgumentsSupported(chat, "search_messages", args)).not.toThrow(); + expect(() => assertGoogleChatToolArgumentsSupported(chat, "send_message", { text: "isUnread" })).not.toThrow(); + const gmail = { config: { sourceTemplateKey: "gmail" } }; + expect(() => assertGoogleChatToolArgumentsSupported(gmail, "search_messages", { + searchParameters: { isUnread: true }, + })).not.toThrow(); + }); + + it("removes unsupported fields from inline and referenced schemas without mutating the cached schema", () => { + const filters = { type: "object", required: ["isUnread", "keywords"], properties: { + isUnread: { type: "boolean" }, is_unread: { type: "boolean" }, + keywords: { type: "array", items: { type: "string" } }, + } }; + const schema = { type: "object", properties: { + searchParameters: filters, + search_parameters: { anyOf: [{ $ref: "#/$defs/SearchParameters" }, { type: "null" }] }, + pageSize: { type: "integer" }, + }, $defs: { SearchParameters: filters } }; + const reduced = googleChatToolInputSchema(chat, "search_messages", schema); + const expectedFilters = { ...filters, required: ["keywords"], properties: { keywords: filters.properties.keywords } }; + expect(reduced).toMatchObject({ + properties: { searchParameters: expectedFilters, pageSize: { type: "integer" } }, + $defs: { SearchParameters: expectedFilters }, + }); + expect(JSON.stringify(reduced)).not.toMatch(/isUnread|is_unread/); + expect(schema.properties.searchParameters.properties.isUnread).toEqual({ type: "boolean" }); + expect(googleChatToolInputSchema({ config: { sourceTemplateKey: "gmail" } }, "search_messages", schema)).toBe(schema); + }); + + it("replaces upstream unread-search instructions with supported search guidance", () => { + expect(googleChatToolDescription(chat, "search_messages", "Search unread messages.")) + .toContain("Read/unread filtering is not supported."); + expect(googleChatToolDescription(chat, "list_messages", "Read history.")).toBe("Read history."); + }); +}); diff --git a/server/src/services/google-chat-tool-policy.ts b/server/src/services/google-chat-tool-policy.ts new file mode 100644 index 00000000000..4811e33bcd3 --- /dev/null +++ b/server/src/services/google-chat-tool-policy.ts @@ -0,0 +1,90 @@ +import { badRequest } from "../errors.js"; + +type Connection = { config: Record }; + +function record(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; +} + +function isChatSearch(connection: Connection, toolName: string): boolean { + const leaf = (toolName.split(/[.:/]/).pop() ?? toolName) + .replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/_/g, "-").toLowerCase(); + if (leaf !== "search-messages") return false; + const config = connection.config; + const profile = record(config.oauth).connectorProfile; + if (config.sourceTemplateKey === "google-chat" || profile === "chat.read" || profile === "chat.write") return true; + // Also cover existing URL-only connections to the official Chat server. + try { + return new URL(String(config.url)).hostname === "chatmcp.googleapis.com"; + } catch { + return false; + } +} + +const UNREAD_FIELDS = new Set(["isUnread", "is_unread"]); + +export function assertGoogleChatToolArgumentsSupported( + connection: Connection, + toolName: string, + parameters: unknown, +): void { + if (!isChatSearch(connection, toolName)) return; + const supplied = record(parameters); + // Reject presence, including false/null, rather than silently changing the + // requested search. Accept neither proto JSON nor snake_case read-state keys. + for (const candidate of [supplied, record(supplied.searchParameters), record(supplied.search_parameters)]) { + if ([...UNREAD_FIELDS].some((key) => Object.hasOwn(candidate, key))) { + throw badRequest( + "Google Chat read/unread filtering is not supported. Omit isUnread and search by keywords, conversation, sender, or dates instead.", + { code: "google_chat_unread_filter_unsupported" }, + ); + } + } + for (const key of ["searchParameters", "search_parameters"]) { + if (Object.hasOwn(supplied, key) && record(supplied[key]) !== supplied[key]) { + throw badRequest("Google Chat searchParameters must be an object, not encoded JSON or text.", { + code: "google_chat_search_parameters_invalid", + }); + } + } +} + +function withoutUnreadFields(value: unknown): unknown { + if (Array.isArray(value)) return value.map(withoutUnreadFields); + if (value === null || typeof value !== "object") return value; + // Walk inline schemas, composition branches, and $defs/definitions so a + // provider changing schema layout cannot advertise the unsupported filter. + return Object.fromEntries(Object.entries(record(value)).map(([key, child]) => { + if (key === "properties") { + return [key, Object.fromEntries(Object.entries(record(child)) + .filter(([name]) => !UNREAD_FIELDS.has(name)) + .map(([name, schema]) => [name, withoutUnreadFields(schema)]))]; + } + if (key === "required" && Array.isArray(child)) { + return [key, child.filter((name) => !UNREAD_FIELDS.has(name))]; + } + return [key, withoutUnreadFields(child)]; + })); +} + +export function googleChatToolInputSchema( + connection: Connection, + toolName: string, + schema: Record, +): Record { + return isChatSearch(connection, toolName) + ? withoutUnreadFields(schema) as Record + : schema; +} + +export function googleChatToolDescription( + connection: Connection, + toolName: string, + description: string | null, +): string | null { + return isChatSearch(connection, toolName) + ? "Search Google Chat messages by keywords, conversation, sender, mentions, links, or dates. Read/unread filtering is not supported." + : description; +} diff --git a/server/src/services/paperclip-cloud-connector.test.ts b/server/src/services/paperclip-cloud-connector.test.ts index dc1db310b3d..080d0fbf57e 100644 --- a/server/src/services/paperclip-cloud-connector.test.ts +++ b/server/src/services/paperclip-cloud-connector.test.ts @@ -79,6 +79,23 @@ describe("Paperclip Cloud connector", () => { } }); + it.each(["chat.read", "chat.write"] as const)("signs reduced Chat scopes for %s", async (profile) => { + const keys = config(); + const scopes = ["chat.spaces.readonly", "chat.messages.readonly", ...(profile === "chat.write" ? ["chat.messages.create"] : [])] + .map((scope) => `https://www.googleapis.com/auth/${scope}`); + const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + const claims = JSON.parse(Buffer.from(body.request.split(".")[1], "base64url").toString("utf8")); + expect(claims.prf).toBe(profile); + expect(claims.scp).toEqual(scopes); + return Response.json({ confirmationUrl: "https://my.example.test/connections/confirm?session=chat", expiresAt: "2099-01-01T00:00:00Z" }); + }); + const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch }); + await connector.startAuthorization({ subject, companyId, profile, + returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback", returnState: "chat-state" }); + expect(request).toHaveBeenCalledOnce(); + }); + it("starts a signed session with exact endpoint audience and scope contract", async () => { const keys = config(); const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index c4a47bd6d3b..8e17ffc81a3 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -225,6 +225,7 @@ import { narrowestScopeBindings, profileIdsInBindingOrder, } from "./tool-profile-binding-precedence.js"; +import { assertGoogleChatToolArgumentsSupported, googleChatToolDescription, googleChatToolInputSchema } from "./google-chat-tool-policy.js"; import { recordToolRuntimeAuditWriteFailure, TOOL_RUNTIME_AUDIT_WRITE_FAILURE_METRIC, @@ -1282,7 +1283,9 @@ export function projectConnectionMethodToolInputSchema( export function projectedConnectionToolArguments( connection: typeof toolConnections.$inferSelect, parameters: unknown, + toolName: string, ): Record { + assertGoogleChatToolArgumentsSupported(connection, toolName, parameters); const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey @@ -1300,7 +1303,9 @@ export function projectedConnectionToolArguments( export function projectedConnectionToolInputSchema( connection: typeof toolConnections.$inferSelect, inputSchema: Record, + toolName: string, ): Record { + inputSchema = googleChatToolInputSchema(connection, toolName, inputSchema); const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey @@ -1692,7 +1697,9 @@ function toCatalogEntryForConnection( inputSchema: projectedConnectionToolInputSchema( connection, rawCatalogEntry.inputSchema ?? {}, + row.toolName, ), + description: googleChatToolDescription(connection, row.toolName, rawCatalogEntry.description), }; if ( connection.transport === "local_stdio" && diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index e74fa2a6119..574efe6abd2 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -105,6 +105,7 @@ import { projectedConnectionToolArguments, projectedConnectionToolInputSchema, } from "./tool-access.js"; +import { assertGoogleChatToolArgumentsSupported, googleChatToolDescription } from "./google-chat-tool-policy.js"; import { parseRemoteHttpEndpoint } from "./remote-http-endpoint-guard.js"; import { guardedRemoteHttpFetch, @@ -1284,6 +1285,7 @@ export function createToolGatewayService( const inputSchema = projectedConnectionToolInputSchema( connection, catalogEntry.inputSchema ?? {}, + catalogEntry.toolName, ); const outputSchema = catalogEntry.outputSchema ?? null; const annotations = catalogEntry.annotations ?? {}; @@ -1314,7 +1316,7 @@ export function createToolGatewayService( name: gatewayToolName, displayName: catalogEntry.title ?? catalogEntry.toolName, description: - catalogEntry.description ?? + googleChatToolDescription(connection, catalogEntry.toolName, catalogEntry.description) ?? `Connected MCP tool ${catalogEntry.toolName} from ${connection.name}.`, parametersSchema: inputSchema, pluginId: `mcp:${applicationKey ?? application.id}`, @@ -4723,8 +4725,8 @@ export function createToolGatewayService( parameters: unknown, ): Promise { if (tool.providerType !== "mcp_remote_http") return parameters; - const { connection } = await resolveConnectedRemoteTool(session, tool); - return projectedConnectionToolArguments(connection, parameters); + const { connection, entry } = await resolveConnectedRemoteTool(session, tool); + return projectedConnectionToolArguments(connection, parameters, entry.toolName); } async function approvedManagedArgumentsRemainCurrent( @@ -5792,6 +5794,9 @@ export function createToolGatewayService( session, tool, ); + // Recheck immediately before dispatch, including previously approved calls + // and connections whose stored grant/catalog predates scope reduction. + assertGoogleChatToolArgumentsSupported(connection, entry.toolName, parameters); if (useDefaultTimeout && isRailwayConnection(connection) && entry.toolName === `${RAILWAY_TOOL_PREFIX}run-command`) { ms = railwayCommandBudgetMs(parameters); }