Add opt-in Gmail archive capability (#16331)

## 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 <leia@paperclip.ing>
This commit is contained in:
Leia MarshallandLeia Marshall authored and GitHub committed 2026-10-15 09:24:31 -07:00
1 parent afe5b3418d
commit e4ce989099
11 files changed
+384 -8

No files matched your search

@@ -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",
+14 -3
View File
@@ -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) =>
+9 -1
View File
@@ -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
@@ -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"
}
]
}
@@ -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<Record<GoogleWorkspac
}>> = {
"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")]),
@@ -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",
]);
});
@@ -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();
});
});
@@ -0,0 +1,110 @@
import { badRequest } from "../errors.js";
type Connection = { config: Record<string, unknown> };
function record(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
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<string, unknown>,
): Record<string, unknown> {
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;
}
@@ -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);
});
+12 -1
View File
@@ -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(
+7 -1
View File
@@ -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);
}