From e4ce989099388abd7c661445dc267183da69ef77 Mon Sep 17 00:00:00 2001 From: Leia Marshall Date: Thu, 15 Oct 2026 09:24:31 -0700 Subject: [PATCH] Add opt-in Gmail archive capability (#16331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip separates Gmail capabilities by reviewed OAuth scope and tool policy. > - Existing Gmail connections support reading and draft creation but not bounded archiving. > - Gmail archive requires `gmail.modify`, so it must not widen existing read or draft grants. > - This change adds an opt-in archive profile and limits it to removing only the `INBOX` label. > - A final pre-dispatch check preserves that boundary for approved and replayed calls. ## Linked Issues or Issue Description **What existing behavior does this improve?** This improves the existing Gmail connection and tool-governance behavior. **Subsystem affected** Cross-cutting: `packages/shared` and `server`. **Current behavior** Paperclip can read Gmail and create drafts. It does not provide an opt-in, bounded action for archiving a thread. **Proposed behavior** Paperclip offers a separate Read & archive connection. It exposes `unlabel_thread` only for that connection and accepts only `threadId` with `labelIds: ["INBOX"]`. **Reason and benefit** Users can archive processed Gmail threads while sending, trashing, and other label changes remain disabled. **Breaking changes** None. Existing read and draft methods keep their current scopes and defaults. Related work: #15545 adds a unified Google Workspace connector and keeps Gmail draft-only. #15412 adds a separate Gmail REST server without label mutations. Neither PR adds this bounded archive capability. ## What Changed - Added the `gmail.archive` connector profile. - Added managed and customer-owned archive connection methods. - Added an Inbox-only schema and final pre-dispatch argument guard. - Classified archive as destructive and added Ask-first defaults for the archive methods. - Added permission-review records and focused tests. ## Verification - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/server build` - `pnpm --filter @paperclipai/shared typecheck` - Focused Vitest suites: 42 tests passed. - App-definition ingestion: 103 definitions and 0 review flags. - `git diff --check` ## Risks The main risk is accidental scope or operation expansion. The new profile is opt-in. Existing profiles retain their scopes. The gateway validates the exact Inbox-only payload immediately before dispatch. Production rollout requires separate OAuth configuration and fresh consent. ## Model Used Unverified — reliable runtime model metadata was not available. Confirm the model attribution before publishing. ## Checklist - [x] I have included a concise design rationale - [x] I have specified that model attribution is unverified - [x] I have checked ROADMAP.md and found no Gmail archive entry - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have described the issue in this PR - [x] I have not referenced internal or instance-local issues - [x] My branch name describes the change - [x] Local tests and builds pass - [x] I have added or updated tests - [x] I have updated relevant documentation and permission records - [x] I have documented risks - [ ] All hosted CI gates are green - [ ] Greptile and human review are complete --------- Co-authored-by: Leia Marshall --- .../tool-method-permission-reviews.json | 36 ++++++ packages/shared/src/app-definitions.test.ts | 17 ++- packages/shared/src/app-definitions.ts | 10 +- .../shared/src/app-definitions/gmail.json | 77 ++++++++++++ .../shared/src/google-workspace-connectors.ts | 3 +- .../src/__tests__/tool-access-service.test.ts | 34 +++++- .../gmail-archive-tool-policy.test.ts | 78 +++++++++++++ .../src/services/gmail-archive-tool-policy.ts | 110 ++++++++++++++++++ .../services/gmail-tool-governance.test.ts | 6 + server/src/services/tool-access.ts | 13 ++- server/src/services/tool-gateway.ts | 8 +- 11 files changed, 384 insertions(+), 8 deletions(-) create mode 100644 server/src/services/gmail-archive-tool-policy.test.ts create mode 100644 server/src/services/gmail-archive-tool-policy.ts diff --git a/doc/connections/tool-method-permission-reviews.json b/doc/connections/tool-method-permission-reviews.json index d83dd6a8e43..eb07d0400df 100644 --- a/doc/connections/tool-method-permission-reviews.json +++ b/doc/connections/tool-method-permission-reviews.json @@ -651,6 +651,42 @@ "reviewedAt": "2026-09-30", "liveProof": "not-run" }, + { + "app": "gmail", + "method": "paperclip-archive", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.modify" + ], + "capability": "write", + "supportedActions": "Read Gmail and archive a thread by removing only the INBOX label.", + "evidence": [ + "https://developers.google.com/workspace/gmail/api/reference/mcp/tools_list/unlabel_thread", + "https://gmailmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-10-14", + "liveProof": "not-run" + }, + { + "app": "gmail", + "method": "customer-archive-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.modify" + ], + "capability": "write", + "supportedActions": "Read Gmail and archive a thread by removing only the INBOX label.", + "evidence": [ + "https://developers.google.com/workspace/gmail/api/reference/mcp/tools_list/unlabel_thread", + "https://gmailmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-10-14", + "liveProof": "not-run" + }, { "app": "google-calendar", "method": "paperclip-read", diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index 52f73c5f0b7..bbe8d1cf8ee 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -51,6 +51,15 @@ const GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS = [ scopes: [googleScope("gmail.readonly"), googleScope("gmail.compose")], writeTools: ["create_draft"], }, + { + profile: "gmail.archive", + appSlug: "gmail", + serverUrl: "https://gmailmcp.googleapis.com/mcp/v1", + capability: "archive", + riskTier: "S4", + scopes: [googleScope("gmail.readonly"), googleScope("gmail.modify")], + writeTools: ["unlabel_thread"], + }, { profile: "drive.read", appSlug: "google-drive", @@ -214,7 +223,7 @@ const GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS = [ profile: GoogleWorkspaceConnectorProfileId; appSlug: string; serverUrl: string; - capability: "read" | "write" | "draft"; + capability: "read" | "write" | "draft" | "archive"; riskTier: "S3" | "S4"; scopes: readonly string[]; writeTools: readonly string[]; @@ -763,10 +772,10 @@ describe("AppDefinition catalog", () => { APP_DEFINITIONS.find((app) => app.slug === "linear")?.methods[0]?.defaults ?.scopesHint, ).toEqual(["read", "write"])); - it("defaults every new connection action to allowed except Enterpret writes", () => { + it("limits Ask-first defaults to reviewed high-risk connection methods", () => { for (const app of APP_DEFINITIONS) for (const method of app.methods) { - if (app.slug === "enterpret") { + if (app.slug === "enterpret" || (app.slug === "gmail" && method.capabilityProfile?.key === "archive")) { expect(recommendedDefaultsForApp(app, method.key)).toEqual({ access: "all_agents", askFirstRiskLevels: ["write", "destructive"], @@ -788,6 +797,8 @@ describe("AppDefinition catalog", () => { expect(getAvailableConnectionMethod(gmail)?.key).toBe( "customer-draft-oauth", ); + expect(gmail.methods.filter((method) => method.capabilityProfile?.key === "archive") + .map((method) => method.key)).toEqual(["paperclip-archive", "customer-archive-oauth"]); expect( getRecommendedConnectionMethod( drive.methods.filter((candidate) => diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts index 4d7241b643c..e1f56841a94 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -262,7 +262,15 @@ export function recommendedDefaultsForApp(app: AppDefinition, methodKey?: string // concrete app/method. Connection finalization remains a configure-authorized, // audited operation, and Ask first stays available as an operator-selected // policy for any action after the connection is made. - void methodKey; + const method = methodKey + ? app.methods.find((candidate) => candidate.key === methodKey) + : null; + if (app.slug === "gmail" && method?.capabilityProfile?.key === "archive") { + return { + access: "all_agents", + askFirstRiskLevels: ["write", "destructive"], + }; + } // Enterpret's run_graph_query self-reports readOnlyHint but executes Cypher // and has not been established as read-only. Classify it as write (server // classifyRisk) and require Ask first so new token connections do not ship diff --git a/packages/shared/src/app-definitions/gmail.json b/packages/shared/src/app-definitions/gmail.json index a5d4b12cdeb..a0f2dcfaaec 100644 --- a/packages/shared/src/app-definitions/gmail.json +++ b/packages/shared/src/app-definitions/gmail.json @@ -177,6 +177,83 @@ "Google Workspace MCP servers are in Developer Preview." ], "riskTier": "S4" + }, + { + "key": "paperclip-archive", + "label": "Connect with Paperclip", + "transport": "mcp_remote", + "auth": "oauth", + "oauthStrategy": "paperclip_cloud_connector", + "connectorProfile": "gmail.archive", + "capabilityProfile": { + "key": "archive", + "label": "Read & archive", + "description": "Read Gmail and archive threads by removing only the Inbox label." + }, + "grantKinds": [ + "user", + "organization" + ], + "ownershipModes": [ + "platform_shared" + ], + "whenToUse": "Use Paperclip-managed Google OAuth to read Gmail and archive threads.", + "defaults": { + "serverUrl": "https://gmailmcp.googleapis.com/mcp/v1", + "scopesHint": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.modify" + ] + }, + "guidanceMd": "Connect Gmail to search and read mail and archive threads. Archive removes only the Inbox label; sending, trashing, and other label changes remain disabled.", + "warnings": [ + "Archiving requires approval and only removes the Inbox label.", + "Google Workspace MCP servers are in Developer Preview." + ], + "riskTier": "S4" + }, + { + "key": "customer-archive-oauth", + "label": "Use your own Google OAuth app", + "transport": "mcp_remote", + "auth": "oauth", + "capabilityProfile": { + "key": "archive", + "label": "Read & archive", + "description": "Read Gmail and archive threads by removing only the Inbox label." + }, + "grantKinds": [ + "user", + "organization" + ], + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a customer-owned Google OAuth client to read Gmail and archive threads.", + "defaults": { + "serverUrl": "https://gmailmcp.googleapis.com/mcp/v1", + "authorizationEndpoint": "https://accounts.google.com/o/oauth2/v2/auth", + "tokenEndpoint": "https://oauth2.googleapis.com/token", + "metadataUrl": "https://accounts.google.com/.well-known/openid-configuration", + "scopesHint": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.modify" + ], + "oauthAuthorizationParams": { + "access_type": "offline", + "prompt": "consent" + } + }, + "guidanceMd": "Enable the Gmail and Gmail MCP APIs in your Google Cloud project, then register Paperclip's callback URI. Archive removes only the Inbox label.", + "consoleLinks": { + "register": "https://console.cloud.google.com/auth/clients", + "docs": "https://developers.google.com/workspace/guides/configure-mcp-servers" + }, + "warnings": [ + "Archiving requires approval and only removes the Inbox label.", + "Google Workspace MCP servers are in Developer Preview." + ], + "riskTier": "S4" } ] } diff --git a/packages/shared/src/google-workspace-connectors.ts b/packages/shared/src/google-workspace-connectors.ts index 5800309de16..601f10314c4 100644 --- a/packages/shared/src/google-workspace-connectors.ts +++ b/packages/shared/src/google-workspace-connectors.ts @@ -1,5 +1,5 @@ export const GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS = [ - "gmail.read", "gmail.draft", "drive.read", "drive.write", "docs.read", "docs.write", + "gmail.read", "gmail.draft", "gmail.archive", "drive.read", "drive.write", "docs.read", "docs.write", "sheets.read", "sheets.write", "slides.read", "slides.write", "calendar.read", "calendar.write", "chat.read", "chat.write", "people.read", "workspace-search.read", ] as const; @@ -16,6 +16,7 @@ export const GOOGLE_WORKSPACE_CONNECTOR_PROFILES: Readonly> = { "gmail.read": def("gmail", "https://gmailmcp.googleapis.com/mcp/v1", [auth("gmail.readonly")]), "gmail.draft": def("gmail", "https://gmailmcp.googleapis.com/mcp/v1", [auth("gmail.readonly"), auth("gmail.compose")], ["create_draft"]), + "gmail.archive": def("gmail", "https://gmailmcp.googleapis.com/mcp/v1", [auth("gmail.readonly"), auth("gmail.modify")], ["unlabel_thread"]), "drive.read": def("google-drive", "https://drivemcp.googleapis.com/mcp/v1", [auth("drive.readonly")]), "drive.write": def("google-drive", "https://drivemcp.googleapis.com/mcp/v1", [auth("drive.readonly"), auth("drive.file")], ["copy_file", "create_file"]), "docs.read": def("google-docs", "https://docsmcp.googleapis.com/mcp/v1", [auth("documents.readonly")]), diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 5ea8b6ebba4..8b39407897a 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -5363,7 +5363,11 @@ describeEmbeddedPostgres("tool access service", () => { ).toBe(false); expect( nonPilotGmail.methods.map((method: { key: string }) => method.key), - ).toEqual(["customer-read-oauth", "customer-draft-oauth"]); + ).toEqual([ + "customer-read-oauth", + "customer-draft-oauth", + "customer-archive-oauth", + ]); const pilot = await request( createRouteApp( @@ -5384,6 +5388,34 @@ describeEmbeddedPostgres("tool access service", () => { "paperclip-read", "customer-read-oauth", "customer-draft-oauth", + "customer-archive-oauth", + ]); + + const archivePilotConnector = fakeGoogleWorkspaceConnector( + company.id, + userId, + "gmail.archive", + ); + const archivePilot = await request( + createRouteApp( + db, + boardSessionActor(company.id, "owner", userId), + undefined, + { paperclipCloudConnector: archivePilotConnector }, + ), + ).get(`/api/companies/${company.id}/tools/gallery`); + expect(archivePilot.status).toBe(200); + const archivePilotGmail = archivePilot.body.apps.find( + (app: { slug: string }) => app.slug === "gmail", + ); + expect(archivePilotGmail.ownershipAvailability.platform_shared).toBe(true); + expect( + archivePilotGmail.methods.map((method: { key: string }) => method.key), + ).toEqual([ + "customer-read-oauth", + "customer-draft-oauth", + "paperclip-archive", + "customer-archive-oauth", ]); }); diff --git a/server/src/services/gmail-archive-tool-policy.test.ts b/server/src/services/gmail-archive-tool-policy.test.ts new file mode 100644 index 00000000000..78474f44d3f --- /dev/null +++ b/server/src/services/gmail-archive-tool-policy.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { + assertGmailArchiveToolArgumentsSupported, + gmailArchiveToolDescription, + gmailArchiveToolInputSchema, +} from "./gmail-archive-tool-policy.js"; + +const archive = { config: { + sourceTemplateKey: "gmail", + connectionMethodKey: "paperclip-archive", + oauth: { connectorProfile: "gmail.archive" }, +} }; + +describe("Gmail archive tool policy", () => { + it("accepts only removal of the Inbox label from one thread", () => { + expect(() => assertGmailArchiveToolArgumentsSupported(archive, "unlabel_thread", { + threadId: "thread-123", + labelIds: ["INBOX"], + })).not.toThrow(); + + for (const parameters of [ + { threadId: "thread-123", labelIds: ["STARRED"] }, + { threadId: "thread-123", labelIds: ["INBOX", "UNREAD"] }, + { threadId: "thread-123", label_ids: ["INBOX"] }, + { threadId: "", labelIds: ["INBOX"] }, + { threadId: "thread-123", labelIds: ["INBOX"], extra: true }, + "encoded", + ]) { + expect(() => assertGmailArchiveToolArgumentsSupported(archive, "unlabel_thread", parameters)) + .toThrow(expect.objectContaining({ + status: 400, + details: { code: "gmail_archive_arguments_unsupported" }, + })); + } + }); + + it("requires an explicit archive connection even for the official Gmail endpoint", () => { + for (const config of [ + { sourceTemplateKey: "gmail", connectionMethodKey: "paperclip-read", oauth: { connectorProfile: "gmail.read" } }, + { sourceTemplateKey: "gmail", connectionMethodKey: "paperclip-draft", oauth: { connectorProfile: "gmail.draft" } }, + { url: "https://gmailmcp.googleapis.com/mcp/v1" }, + ]) { + expect(() => assertGmailArchiveToolArgumentsSupported({ config }, "unlabel_thread", { + threadId: "thread-123", labelIds: ["INBOX"], + })).toThrow(expect.objectContaining({ + status: 400, + details: { code: "gmail_archive_profile_required" }, + })); + } + }); + + it("publishes an Inbox-only schema without mutating the provider schema", () => { + const providerSchema = { type: "object", properties: { + threadId: { type: "string" }, + labelIds: { type: "array", items: { type: "string" } }, + } }; + const projected = gmailArchiveToolInputSchema(archive, "gmail/unlabel_thread", providerSchema); + expect(projected).toMatchObject({ + properties: { labelIds: { items: { enum: ["INBOX"] }, minItems: 1, maxItems: 1 } }, + required: ["threadId", "labelIds"], + additionalProperties: false, + }); + expect(providerSchema.properties.labelIds.items).toEqual({ type: "string" }); + }); + + it("describes archive narrowly and leaves unrelated tools unchanged", () => { + expect(gmailArchiveToolDescription(archive, "unlabel_thread", "Remove labels.")) + .toContain("removing only its Inbox label"); + expect(gmailArchiveToolDescription(archive, "search_threads", "Search mail.")) + .toBe("Search mail."); + expect(() => assertGmailArchiveToolArgumentsSupported( + { config: { sourceTemplateKey: "other" } }, + "unlabel_thread", + { anything: true }, + )).not.toThrow(); + }); +}); diff --git a/server/src/services/gmail-archive-tool-policy.ts b/server/src/services/gmail-archive-tool-policy.ts new file mode 100644 index 00000000000..1bc9462de92 --- /dev/null +++ b/server/src/services/gmail-archive-tool-policy.ts @@ -0,0 +1,110 @@ +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 leafName(name: string): string { + return (name.split(/[.:/]/).pop() ?? name) + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/_/g, "-") + .toLowerCase(); +} + +function isOfficialGmailConnection(connection: Connection): boolean { + const config = connection.config; + const profile = record(config.oauth).connectorProfile; + if ( + config.sourceTemplateKey === "gmail" + || profile === "gmail.read" + || profile === "gmail.draft" + || profile === "gmail.archive" + ) return true; + try { + return new URL(String(config.url)).hostname === "gmailmcp.googleapis.com"; + } catch { + return false; + } +} + +function isArchiveConnection(connection: Connection): boolean { + const config = connection.config; + return record(config.oauth).connectorProfile === "gmail.archive" + || (config.sourceTemplateKey === "gmail" + && (config.connectionMethodKey === "paperclip-archive" + || config.connectionMethodKey === "customer-archive-oauth")); +} + +function isUnlabelThread(connection: Connection, toolName: string): boolean { + return isOfficialGmailConnection(connection) && leafName(toolName) === "unlabel-thread"; +} + +export function assertGmailArchiveToolArgumentsSupported( + connection: Connection, + toolName: string, + parameters: unknown, +): void { + if (!isUnlabelThread(connection, toolName)) return; + if (!isArchiveConnection(connection)) { + throw badRequest("Gmail archiving requires an explicit Read & archive connection.", { + code: "gmail_archive_profile_required", + }); + } + const supplied = record(parameters); + const keys = Object.keys(supplied).sort(); + if ( + keys.length !== 2 + || keys[0] !== "labelIds" + || keys[1] !== "threadId" + || typeof supplied.threadId !== "string" + || supplied.threadId.trim().length === 0 + || !Array.isArray(supplied.labelIds) + || supplied.labelIds.length !== 1 + || supplied.labelIds[0] !== "INBOX" + ) { + throw badRequest("Gmail archive accepts only threadId and labelIds: [\"INBOX\"].", { + code: "gmail_archive_arguments_unsupported", + }); + } +} + +export function gmailArchiveToolInputSchema( + connection: Connection, + toolName: string, + schema: Record, +): Record { + if (!isUnlabelThread(connection, toolName) || !isArchiveConnection(connection)) return schema; + return { + type: "object", + properties: { + threadId: { + type: "string", + minLength: 1, + description: "The Gmail thread ID to archive.", + }, + labelIds: { + type: "array", + items: { type: "string", enum: ["INBOX"] }, + minItems: 1, + maxItems: 1, + description: "Must be exactly [\"INBOX\"].", + }, + }, + required: ["threadId", "labelIds"], + additionalProperties: false, + }; +} + +export function gmailArchiveToolDescription( + connection: Connection, + toolName: string, + description: string | null, +): string | null { + return isUnlabelThread(connection, toolName) && isArchiveConnection(connection) + ? "Archive a Gmail thread by removing only its Inbox label. Requires human approval." + : description; +} diff --git a/server/src/services/gmail-tool-governance.test.ts b/server/src/services/gmail-tool-governance.test.ts index 526f7b5b516..9d0b0b27396 100644 --- a/server/src/services/gmail-tool-governance.test.ts +++ b/server/src/services/gmail-tool-governance.test.ts @@ -12,6 +12,8 @@ describe("Gmail tool governance", () => { expect(isGmailToolPermanentlyBlocked({ name: "get_message" })).toBe(false); expect(classifyRisk({ name: "create_draft" }, "gmail")).toBe("write"); expect(isGmailToolPermanentlyBlocked({ name: "create_draft" })).toBe(false); + expect(classifyRisk({ name: "unlabel_thread" }, "gmail")).toBe("destructive"); + expect(isGmailToolPermanentlyBlocked({ name: "unlabel_thread" })).toBe(false); expect(isGmailToolPermanentlyBlocked({ name: "send_message" })).toBe(true); expect(isGmailToolPermanentlyBlocked({ name: "trash_thread" })).toBe(true); @@ -28,6 +30,10 @@ describe("Google Workspace tool governance", () => { expect(isGoogleWorkspaceToolAllowed("gmail.draft", { name: "create_draft" })).toBe(true); expect(isGoogleWorkspaceToolAllowed("gmail.draft", { name: "send_message" })).toBe(false); + expect(isGoogleWorkspaceToolAllowed("gmail.archive", { name: "search_threads" })).toBe(true); + expect(isGoogleWorkspaceToolAllowed("gmail.archive", { name: "unlabel_thread" })).toBe(true); + expect(isGoogleWorkspaceToolAllowed("gmail.archive", { name: "label_thread" })).toBe(false); + expect(isGoogleWorkspaceToolAllowed("gmail.archive", { name: "send_message" })).toBe(false); expect(isGoogleWorkspaceToolAllowed("calendar.write", { name: "delete_event" })).toBe(true); expect(isGoogleWorkspaceToolAllowed("calendar.write", { name: "publish_calendar" })).toBe(false); }); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 7a3d42eed73..f37c81032fe 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -253,6 +253,7 @@ import { profileIdsInBindingOrder, } from "./tool-profile-binding-precedence.js"; import { assertGoogleChatToolArgumentsSupported, googleChatToolDescription, googleChatToolInputSchema } from "./google-chat-tool-policy.js"; +import { gmailArchiveToolDescription, gmailArchiveToolInputSchema } from "./gmail-archive-tool-policy.js"; import { recordToolRuntimeAuditWriteFailure, TOOL_RUNTIME_AUDIT_WRITE_FAILURE_METRIC, @@ -1343,6 +1344,7 @@ export function projectedConnectionToolInputSchema( const managed = honchoManagedArguments(connection, inputSchema); if (managed) inputSchema = stripManagedToolArgumentSchema(inputSchema, managed); inputSchema = googleChatToolInputSchema(connection, toolName, inputSchema); + inputSchema = gmailArchiveToolInputSchema(connection, toolName, inputSchema); const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey @@ -1737,7 +1739,11 @@ function toCatalogEntryForConnection( rawCatalogEntry.inputSchema ?? {}, row.toolName, ), - description: googleChatToolDescription(connection, row.toolName, rawCatalogEntry.description), + description: gmailArchiveToolDescription( + connection, + row.toolName, + googleChatToolDescription(connection, row.toolName, rawCatalogEntry.description), + ), }; if ( connection.transport === "local_stdio" && @@ -2409,6 +2415,10 @@ export function classifyRisk( // safe. Treat it as write so Ask-first defaults can restrict it. if (sourceTemplateKey === "enterpret" && normalizedToolName === "run-graph-query") return "write"; + // Gmail archive removes a system label. Keep it behind the archive method's + // destructive Ask-first default even when the preview provider omits hints. + if (sourceTemplateKey === "gmail" && normalizedToolName === "unlabel-thread") + return "destructive"; if (sourceTemplateKey === "posthog" && normalizedToolName === "exec") return "destructive"; // Superagent mirrors its REST API, and several mutations use verbs the @@ -2460,6 +2470,7 @@ export function classifyRisk( export function isGmailToolPermanentlyBlocked( tool: McpToolDescriptor, ): boolean { + if (googleWorkspaceToolLeafName(tool.name) === "unlabel-thread") return false; const riskLevel = classifyRisk(tool, "gmail"); return ( verbMatches( diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index f7fe7f1617b..3c28f51db62 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -124,6 +124,7 @@ import { projectedConnectionToolInputSchema, } from "./tool-access.js"; import { assertGoogleChatToolArgumentsSupported, googleChatToolDescription } from "./google-chat-tool-policy.js"; +import { assertGmailArchiveToolArgumentsSupported, gmailArchiveToolDescription } from "./gmail-archive-tool-policy.js"; import { parseRemoteHttpEndpoint } from "./remote-http-endpoint-guard.js"; import { guardedRemoteHttpFetch, @@ -1470,7 +1471,11 @@ export function createToolGatewayService( name: gatewayToolName, displayName: catalogEntry.title ?? catalogEntry.toolName, description: - googleChatToolDescription(connection, catalogEntry.toolName, catalogEntry.description) ?? + gmailArchiveToolDescription( + connection, + catalogEntry.toolName, + googleChatToolDescription(connection, catalogEntry.toolName, catalogEntry.description), + ) ?? `Connected MCP tool ${catalogEntry.toolName} from ${connection.name}.`, parametersSchema: inputSchema, pluginId: `mcp:${applicationKey ?? application.id}`, @@ -5972,6 +5977,7 @@ export function createToolGatewayService( // Recheck immediately before dispatch, including previously approved calls // and connections whose stored grant/catalog predates scope reduction. assertGoogleChatToolArgumentsSupported(connection, entry.toolName, parameters); + assertGmailArchiveToolArgumentsSupported(connection, entry.toolName, parameters); if (useDefaultTimeout && isRailwayConnection(connection) && entry.toolName === `${RAILWAY_TOOL_PREFIX}run-command`) { ms = railwayCommandBudgetMs(parameters); }