fix(apps): reduce Google Chat scopes and block unread filters (#13820)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Apps give agents controlled access to external services.
> - Google Chat uses OAuth profiles and reviewed MCP tools.
> - Those profiles request membership and read-state access that the
supported feature set does not need.
> - Removing read-state access also requires us to block unread search
filters, including on existing connections.
> - This pull request reduces both OAuth methods and enforces the
reduced search contract before dispatch.
> - Users retain conversation lookup, message history, ordinary search,
and approved message sending.

## Linked Issues or Issue Description

**What happened?**

Both Google Chat profiles request membership and read-state scopes. The
supported tool set does not include membership listing or read-state
updates. Message search still advertises an unread filter. Related work:
Refs #12619.

**Expected behavior**

Managed and customer-owned OAuth request only the scopes needed for
supported features. Unsupported unread filters fail clearly before any
provider call. Existing cached catalogs and broader grants must not
bypass that policy.

**Steps to reproduce**

Start Google Chat OAuth from either connection method and inspect the
requested scopes. Inspect the message-search tool schema, then submit a
search with `searchParameters.isUnread` set to true or false.

**Paperclip version or commit**

The scope change is based on master at `110d176fc`.

**Deployment mode**

Managed Cloud and self-hosted instances with Google Chat Apps enabled.

## What Changed

- Remove `chat.memberships.readonly` and `chat.users.readstate.readonly`
from shared profiles and all four Chat connection methods.
- Hide unsupported read-state fields and instructions in agent and board
Test tool schemas.
- Reject explicit unread filters, including false, null, snake-case
fields, and encoded filter objects, before provider dispatch.
- Recheck previously approved calls and support existing profile-bound
and URL-only Chat connections.
- Add scope, signed broker request, OAuth URL, allowlist, schema, and
dispatch regression tests.
- Document coordinated app/broker rollout, existing-grant reconnects,
and the remaining deployment checks.

## Verification

- All seven focused OAuth and Chat gateway test files pass: 498 tests on
the rebased branch.
- `pnpm -r typecheck` and `pnpm build` pass, using pinned pnpm 9.15.4.
- The complete sharded CI test matrix passes, including general server,
Chat, workspace, serialized server, Runner, and all eight browser e2e
shards. The duplicate unsharded local `pnpm test:run` was stopped after
CI passed; it did not complete locally.
- `git diff --check` passes.
- `node scripts/ingest-app-definitions.mjs` succeeds and leaves the
branch unchanged. Google Workspace JSON is the durable reviewed input
used by the generator.
- All current-head CI checks pass at
`7ee755371714dc036fff1c7da844776fee3f2ec1`, including typecheck, build,
and canary dry run. Greptile is 5/5 with zero unresolved threads. The
generator concern was withdrawn after review of the source and
regeneration evidence.
- No production deployment or live Google consent test was performed.
After coordinated deployment, verify reduced consent scopes, normal
search/history, message sending, and rejection of unread filters.

## Risks

- Coordinate deployment with the companion Cloud broker scope change.
Mixed versions can reject exact-scope requests.
- Existing tokens are not narrowed or revoked. Grants with old scopes
need new consent. Do not revoke a shared Google client to migrate one
profile.
- Explicit unread filters now return an error instead of being sent to
Google. Ordinary search and the approved send tool remain available.
- No database, UI, lockfile, or workflow changes.

## Model Used

OpenAI Codex, a GPT-5-based coding agent, with tool use and code
execution. The exact runtime model ID and context window were not
exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-22 15:12:30 -05:00
1 parent 9a76390cfa
commit a959e47508
12 files changed
+352 -23

No files matched your search

+30
View File
@@ -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.
@@ -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"],
@@ -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"
@@ -26,8 +26,8 @@ export const GOOGLE_WORKSPACE_CONNECTOR_PROFILES: Readonly<Record<GoogleWorkspac
"slides.write": def("google-slides", "https://slidesmcp.googleapis.com/mcp/v1", [auth("drive.readonly"), auth("drive.file"), auth("presentations")], ["update_presentation"]),
"calendar.read": def("google-calendar", "https://calendarmcp.googleapis.com/mcp/v1", [auth("calendar.calendarlist.readonly"), auth("calendar.events.freebusy"), auth("calendar.events.readonly")]),
"calendar.write": def("google-calendar", "https://calendarmcp.googleapis.com/mcp/v1", [auth("calendar.calendarlist.readonly"), auth("calendar.events.freebusy"), auth("calendar.events")], ["create_event", "update_event", "delete_event", "respond_to_event"]),
"chat.read": def("google-chat", "https://chatmcp.googleapis.com/mcp/v1", [auth("chat.spaces.readonly"), auth("chat.memberships.readonly"), auth("chat.messages.readonly"), auth("chat.users.readstate.readonly")]),
"chat.write": def("google-chat", "https://chatmcp.googleapis.com/mcp/v1", [auth("chat.spaces.readonly"), auth("chat.memberships.readonly"), auth("chat.messages.readonly"), auth("chat.users.readstate.readonly"), auth("chat.messages.create")], ["send_message"]),
"chat.read": def("google-chat", "https://chatmcp.googleapis.com/mcp/v1", [auth("chat.spaces.readonly"), auth("chat.messages.readonly")]),
"chat.write": def("google-chat", "https://chatmcp.googleapis.com/mcp/v1", [auth("chat.spaces.readonly"), auth("chat.messages.readonly"), auth("chat.messages.create")], ["send_message"]),
"people.read": def("google-people", "https://people.googleapis.com/mcp/v1", [auth("directory.readonly"), auth("userinfo.profile"), auth("contacts.readonly")]),
"workspace-search.read": def("google-workspace-search", "https://workspacemcp.googleapis.com/mcp/v1", [auth("gmail.readonly"), auth("drive.readonly"), auth("calendar.readonly"), auth("chat.messages.readonly")]),
};
@@ -2410,6 +2410,36 @@ describeEmbeddedPostgres("tool access service", () => {
);
});
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<string, unknown>), 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);
@@ -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);
@@ -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);
});
});
@@ -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.");
});
});
@@ -0,0 +1,90 @@
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 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<string, unknown>,
): Record<string, unknown> {
return isChatSearch(connection, toolName)
? withoutUnreadFields(schema) as Record<string, unknown>
: 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;
}
@@ -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) => {
+7
View File
@@ -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<string, unknown> {
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<string, unknown>,
toolName: string,
): Record<string, unknown> {
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" &&
+8 -3
View File
@@ -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<unknown> {
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);
}