diff --git a/doc/connections/CONNECTOR-PERMISSION-AUDIT.md b/doc/connections/CONNECTOR-PERMISSION-AUDIT.md new file mode 100644 index 0000000000..07942d7504 --- /dev/null +++ b/doc/connections/CONNECTOR-PERMISSION-AUDIT.md @@ -0,0 +1,160 @@ +# Tool connection permission audit — 2026-09-30 + +This review covers 117 tool methods (84 OAuth methods). The machine-readable +[source of reviewed defaults](./tool-method-permission-reviews.json) lists each +method's exact requested scopes, supported actions, restrictions, sources and +verification limits. AI runtime authentication and chat/channel setup are +separate contracts and were not changed. + +## Shared credential failure + +Zapier's secret URL exposed a shared ownership/resolution problem. The same +invariants apply to personal pasted-key and custom-header methods, including +Bitly, Cloudflare, Coda, Fireflies, GitHub PATs, Kernel, O'Reilly, PagerDuty, +PostHog, Postman, Razorpay, Sanity, Similarweb, Stripe, Supabase and You.com. +Generic MCP setup uses this path too, so “Connect your own” was not a reliable +workaround. This is a code-path finding, not a claim that every provider was +tested with a live account. + +Personal invocation secrets now belong to the grant's user. Setup, health, +discovery and the run gateway share ownership checks and canonical credential +paths. Public unauthenticated URLs need no credential repair. OAuth client +registration secrets remain company-owned and separate from invocation tokens. +Existing connections with incorrectly owned credentials require the owner to +reconnect with a fresh key or secret URL. There is no automatic ownership backfill. + +## Changes + +- Airtable now requests its seven documented record, schema, comment and + workspace/base scopes, including writes. +- Explicit scope sets were added for providers that advertise a scoped MCP + resource, including Beehiiv, Netlify, Miro, Sentry, Supabase, Todoist and + TickTick. Read and write remain subject to provider roles and resource selection. +- Hugging Face adds repository read/contribution and jobs to `read-mcp`. + Optional tools still need enabling in HF's MCP settings. +- Slack's reviewed set now covers the documented message, channel, reaction, + canvas, file and list actions. It replaces the obsolete generic search scope + with the documented search scopes. The fixed app/approval gate remains. +- Google write/draft methods take priority over managed read-only methods when + available. Existing Google profiles, preview verification and availability + gates remain. People and Workspace Search stay read-only. +- The hosted Fireflies server also exposes meeting sharing, renaming, moving and + soundbite creation. Its identity scopes are retained; meeting ownership and + team roles decide which mutations succeed. +- Provider read-only choices stay available under Advanced. Existing OAuth + tokens and action restrictions are untouched. Permissions offers reconnect + when provider consent needs changing. + +## Provider-default exceptions + +These are deliberate exceptions to supplying `scopesHint`, not claims that an +old grant can write. Several servers put resource/permission selection in their +own consent screen; Box uses registered app scopes, and managed GitHub uses +installation permissions. An authorization server's general scope catalog is +not a safe substitute for the MCP permission contract. + +| Provider / method | Reason and limits | +| --- | --- | +| bitly / `mcp-oauth` | The official MCP quickstart uses browser authorization without client-selected scopes; neither protected-resource nor authorization-server metadata advertises a scope list. Bitly account permissions govern link actions. [Evidence](https://dev.bitly.com/bitly-mcp/overview/quickstart/) | +| box / `mcp-own-oauth` | Box requires scopes (including Manage AI) on the registered Box app in the Admin Console. Its MCP authorization metadata has no request-scope list; consent and the app registration control file operations. [Evidence](https://support.box.com/hc/en-us/articles/43847256139923-Managing-Box-MCP-Servers) | +| egnyte / `mcp-oauth` | The hosted authorization server selects the Egnyte tenant/account permissions. Neither MCP nor authorization-server metadata advertises request scopes. Documentation rendered no readable body during this review; tenant-bound write proof remains required. [Evidence](https://developers.egnyte.com/docs/Remote_MCP_Server) | +| github / `managed` | Managed github.code uses a GitHub App installation and selected repositories. Installation permissions, not OAuth scope strings, grant code and pull-request writes; retain the managed profile. [Evidence](https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp/) | +| make / `mcp-oauth` | Make documents selecting scopes and scenarios during its hosted connection flow. Management tools require a paid plan. Do not replace that resource selection with invented MCP client scopes. [Evidence](https://developers.make.com/mcp-server) | +| oauth-generic / `oauth` | Operator-defined OAuth endpoints have no provider-specific reviewed scope list. Request only the scopes supplied by the operator; this template is not a curated provider allowlist. [Evidence](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) | +| planetscale / `mcp-oauth` | PlanetScale documents choosing organizations, databases and read/write permission at authorization. Its general authorization server advertises unrelated organization administration too; retain the provider consent selection, and the separate insights-only endpoint. [Evidence](https://planetscale.com/docs/connect/mcp) | +| planetscale / `mcp-insights-only` | PlanetScale documents choosing organizations, databases and read/write permission at authorization. Its general authorization server advertises unrelated organization administration too; retain the provider consent selection, and the separate insights-only endpoint. [Evidence](https://planetscale.com/docs/connect/mcp) | +| posthog / `mcp-oauth` | PostHog documents the no-scope MCP setup as read/write, with optional readonly and feature filters. The authorization server advertises account administration unrelated to many tools; retain its MCP consent defaults and the existing explicit filters. [Evidence](https://posthog.com/docs/model-context-protocol) | +| postman / `mcp-oauth-minimal` | The official remote MCP setup uses browser sign-in and endpoint-selected minimal/code/full tool catalogs. Its protected-resource and authorization-server metadata publish no scope set; account/workspace rights govern writes. [Evidence](https://learning.postman.com/latest-v-12/docs/reference/postman-api/postman-mcp-server/postman-mcp-remote-server) | +| postman / `mcp-oauth-code` | The official remote MCP setup uses browser sign-in and endpoint-selected minimal/code/full tool catalogs. Its protected-resource and authorization-server metadata publish no scope set; account/workspace rights govern writes. [Evidence](https://learning.postman.com/latest-v-12/docs/reference/postman-api/postman-mcp-server/postman-mcp-remote-server) | +| postman / `mcp-oauth-full` | The official remote MCP setup uses browser sign-in and endpoint-selected minimal/code/full tool catalogs. Its protected-resource and authorization-server metadata publish no scope set; account/workspace rights govern writes. [Evidence](https://learning.postman.com/latest-v-12/docs/reference/postman-api/postman-mcp-server/postman-mcp-remote-server) | +| razorpay / `mcp-oauth` | Neither protected-resource nor authorization-server metadata advertises scopes. Keep hosted provider consent; the previous official OAuth documentation URL returned 404 during review. Account-bound writes need live confirmation. [Evidence](https://razorpay.com/docs/mcp-server/oauth/) | +| ticket-tailor / `mcp-oauth` | Hosted authorization controls box-office API-key permissions; do not treat OAuth sign-in as increasing the underlying key permissions. This method requires account-bound proof. [Evidence](https://developers.tickettailor.com/docs/mcp/authentication/) | +| webflow / `mcp-oauth` | The official MCP setup installs the Bridge App and asks users to authorize sites. Neither protected-resource nor authorization-server metadata publishes a request-scope list; the installed app and site selection govern writes. [Evidence](https://developers.webflow.com/mcp/reference/getting-started) | + +## Read-only and provider-controlled methods + +Candid, Context7, O'Reilly, Similarweb and You.com expose retrieval/research +capabilities rather than document/account mutation. PlanetScale's insights-only +endpoint deliberately excludes query execution. Xero's current published MCP +scope set includes invoice/report reads and settings access, without invoice +write permission. Do not widen these using unrelated general API scopes. + +Scopes such as `openid`, `global`, `mcp:all` or `workspace:member` do not have to +contain the word “write” to authorize work. The provider's MCP tool contract, +role and consent selection determine their meaning. In particular, Cloudflare, +Vercel, Wix, Kernel and Supermemory retain their provider-side permission gates. + +## Evidence and verification boundaries + +Official docs and unauthenticated protected-resource/authorization metadata were +retrieved on 2026-09-30. The review records source URLs per method. Some pages +(especially Egnyte and Razorpay's old OAuth URL) were unavailable or unreadable; +those entries explicitly retain that limitation. Embat publishes identity +metadata but no verified write contract. No account-bound write guarantee is +made for these entries. + +No provider account was used for live read/write verification in this task. +In particular, public scope metadata is **not** proof that an existing token has +those permissions or that an account has the required paid plan, resource ACLs, +admin approval, or Google preview enrollment. Use test accounts/resources for +that proof before claiming provider-level acceptance. + +Backend regressions execute catalog Zapier, generic secret URLs, personal +bearer/custom headers, organization credentials and public URLs through the +run-scoped gateway using a fixture transport. They assert canonical persisted +ownership/declarations and run read and write calls. Reconnect regressions cover +replacing legacy company credentials with user-owned values and declarations. +OAuth fixtures assert exact authorization scopes and rejection of an unrelated +advertised admin scope; they do not contact provider accounts. + +The managed worktree has a separate instance configuration and database path. +Local CLI provisioning was attempted with both minimal and full seed modes; +both failed while applying the copied database's migrations because +`tool_connections_transport_check` was missing. The development instance was +not started from that clone. Clean fixture databases were used for the backend and Apps browser +checks; their success does not establish successful seeding of that local copy. + +### Embedded-browser acceptance + +On 2026-09-30, a hands-on walkthrough used the PR checkout's built UI and actual +server, a fresh isolated database created through CLI onboarding, and a local +HTTP MCP fixture. Starting from the sidebar's Connectors page, the operator +selected “Just me,” entered a bearer key, and created and read back a disposable +widget through the Permissions screen's agent test controls. + +After seeding the legacy ownership mismatch in that disposable connection, +invocation and catalog refresh rejected it with `grant_credential_invalid`. +The catalog showed “Needs attention” and offered Reconnect. Replacing the key +through that UI preserved the connection and personal grant, created a fresh +user-owned secret and canonical declaration, and restored writes. The legacy +company secret retained its ownership. Separate HTTP calls through a session +bound to a fixture agent run also completed a write and read-back. + +The walkthrough exposed and verified fixes for a false “Still not working” +message after successful reconnect, incorrect action-input advice for ownership +errors, and Cancel attempting to save an invalid Zapier URL. The browser +reconnect regression now performs the replacement through the form and checks +that the stale warning disappears. + +A second personal connection used a secret URL. After reproducing its legacy +ownership failure, the browser could replace the URL and create/read back a +widget. Generic reconnect now derives URL/header fields from the stored +credential placement instead of assuming a bearer key, and rejects replacement +URLs for a different public endpoint. A new public, organization-wide connection +also appeared immediately when returning to Browse, without a page reload. +The walkthrough fixed setup cache invalidation and a cramped reconnect banner. + +Zapier's URL validation and cancellation were exercised, but a live Zapier +connection was not completed. Gmail stopped at instance enrollment. Neither +journey establishes provider-account consent or live provider read/write proof. + +### Automated verification + +- Run `pnpm -r typecheck`, `pnpm build`, and `pnpm check:token-gates`. +- The affected Apps browser suites passed 10 tests, with one existing restart + case skipped. These used clean, isolated fixture databases. +- Run `pnpm test:run` for the full suite. Current-head CI and local results are + recorded in the pull request; fixture coverage is distinct from provider proof. +- New gateway fixtures execute both read and write calls through real run-scoped + gateway sessions. They also verify that invalid ownership requires reconnect + and that the owner's fresh credentials restore access without changing identity. diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index 04f8d38b6d..950656cbf2 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -61,6 +61,59 @@ execution. Extend the provider's existing catalog entry with typed AI methods; reuse the existing login controllers. See [AI Connections](./AI-CONNECTIONS.md) for compatibility, personal defaults, resolver isolation, and legacy adoption. +## Read/write defaults and credential ownership + +New tool connections request the provider-documented permissions needed for their +supported read **and write** actions. Prefer an available write/draft capability +before a managed read-only method. Read-only capability choices and provider +read-only switches belong under **Advanced**. Preserve an explicit method on +resume/reconnect. Keep the Access → Connect flow and manage action restrictions +on Permissions; changing OAuth configuration never changes existing consent or +turns an Off/Ask-first action into Allowed. + +Every tool method must have a review in +[`tool-method-permission-reviews.json`](./tool-method-permission-reviews.json). +It records requested scopes, supported actions, provider restrictions, official +evidence, and live-proof status. The ingestion script uses its explicit scope +lists as `scopesHint`; the catalog regression rejects missing reviews, drift, +and undocumented omitted OAuth scopes. An omission requires a provider-default +exception explaining how consent/registration grants access. Never automatically +request all scopes advertised by an authorization server. See the +[permission audit](./CONNECTOR-PERMISSION-AUDIT.md) for review findings and limits. +API-key fields must explain required provider permissions; Paperclip cannot +increase an already-issued key's permissions. Reconnect with fresh consent/key +when access is insufficient. A provider's explicit `insufficient_scope` response +becomes an actionable `oauth_insufficient_scope` error; provider response text +and credentials are not echoed. + +All invocation credential writes use `writeConnectionCredential` (setup, +replacement, reconnect, OAuth completion and rotation). A personal grant requires +a **user-scoped** secret owned by its subject and a user-secret definition and +declaration for the connection. Organization/dedicated-agent credentials use +company secrets and bindings. OAuth **client-registration** secrets remain +company-owned and are resolved separately from action credentials. Declaration +paths are canonical (`credentials.authorization`, `headers.X-Api-Key`, +`remote.url`, `oauth.access_token`), never double-prefixed. Health, discovery, +board tests, invocation, and version tracking must use the selected grant's +refs and the same ownership checks. A personal resolver must not fall back to a +company credential. Ownership mismatch is `grant_credential_invalid` and tells +the owner to reconnect. + +Existing personal connections with company-scoped invocation credentials require +their owner to reconnect and enter a fresh key or secret URL. Reconnect creates +a user-owned credential and updates the grant and declarations while preserving +the connection identity and action policies. Credential ownership is never +automatically reassigned at startup. Health, discovery and invocation reject an +invalid ownership layout with an actionable reconnect error. + +Verification must assert stored ownership and declarations, then execute a read +and a write through a real run-scoped gateway. Cover generic and curated URL +credentials, bearer/custom headers, shared identities, public endpoints, rotation, +removal, failed-setup cleanup, owner reconnect of legacy credentials, another user, +and another company. Fixture-backed MCP calls prove Paperclip behavior; they do +not prove provider consent or account entitlements. Record account-bound live +read/write proof separately and never describe metadata discovery as live proof. + ## Contents - [Mental model and support matrix](#mental-model-five-independent-axes) diff --git a/doc/connections/tool-method-permission-reviews.json b/doc/connections/tool-method-permission-reviews.json new file mode 100644 index 0000000000..162bc2b09c --- /dev/null +++ b/doc/connections/tool-method-permission-reviews.json @@ -0,0 +1,2149 @@ +{ + "reviewedAt": "2026-09-30", + "evidenceLevel": "Official documentation and public MCP metadata; no account-bound read/write proof. Runtime discovery never expands this allowlist.", + "methods": [ + { + "app": "airtable", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "data.records:read", + "data.records:write", + "schema.bases:read", + "schema.bases:write", + "data.recordComments:read", + "data.recordComments:write", + "workspacesAndBases:read" + ], + "capability": "write", + "supportedActions": "Create/update records, schema and record comments in selected bases.", + "evidence": [ + "https://airtable.com/developers/agents/mcp/getting-started", + "https://support.airtable.com/articles/9897799762-using-the-airtable-mcp-server", + "https://mcp.airtable.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "api-key-generic", + "method": "api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Actions depend on the operator-supplied API and key; grant read/write on the required resources at the provider.", + "evidence": [ + "https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Actions depend on the operator-supplied API and key; grant read/write on the required resources at the provider. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions.", + "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." + }, + { + "app": "arcade", + "method": "mcp", + "auth": "none", + "policy": "endpoint", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Actions depend on the user-configured endpoint and its upstream authorizations.", + "evidence": [ + "https://docs.arcade.dev/en/operate/governance/mcp-gateways" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "asana", + "method": "mcp-own-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "default" + ], + "capability": "write", + "supportedActions": "Create/update tasks and projects authorized by the account.", + "evidence": [ + "https://developers.asana.com/docs/integrating-with-asanas-mcp-server", + "https://mcp.asana.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "beehiiv", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "read", + "write" + ], + "capability": "write", + "supportedActions": "Publication/subscriber actions exposed by the MCP catalog.", + "evidence": [ + "https://www.beehiiv.com/features/mcp/getting-started", + "https://mcp.beehiiv.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "bitly", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "provider-default", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Create/manage links in authorized groups.", + "evidence": [ + "https://dev.bitly.com/bitly-mcp/overview/quickstart/", + "https://api-ssl.bitly.com/.well-known/oauth-protected-resource", + "https://api-ssl.bitly.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "providerDefaultReason": "The official MCP quickstart uses browser authorization without client-selected scopes; neither protected-resource nor authorization-server metadata advertises a scope list. Bitly account permissions govern link actions." + }, + { + "app": "bitly", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Create/manage links in authorized groups.", + "evidence": [ + "https://dev.bitly.com/bitly-mcp/overview/quickstart/", + "https://api-ssl.bitly.com/.well-known/oauth-protected-resource", + "https://api-ssl.bitly.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Create/manage links in authorized groups. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + }, + { + "app": "box", + "method": "mcp-own-oauth", + "auth": "oauth", + "policy": "provider-default", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "File/folder actions exposed by the configured Box MCP app; app registration and content ACLs apply.", + "evidence": [ + "https://support.box.com/hc/en-us/articles/43847256139923-Managing-Box-MCP-Servers", + "https://mcp.box.com/.well-known/oauth-protected-resource", + "https://api.box.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "providerDefaultReason": "Box requires scopes (including Manage AI) on the registered Box app in the Admin Console. Its MCP authorization metadata has no request-scope list; consent and the app registration control file operations." + }, + { + "app": "brex", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "openid", + "offline_access", + "email", + "users.readonly", + "departments.readonly", + "locations.readonly", + "titles.readonly", + "legal_entities.readonly", + "cards.readonly", + "cards", + "companies.readonly", + "budgets.readonly", + "travel.trips.readonly", + "expenses.card.readonly", + "expenses.card", + "expenses.bill", + "accounts.cash.readonly", + "vendors.readonly", + "accounting.integration.read", + "accounting.record.read" + ], + "capability": "provider-controlled", + "supportedActions": "Own card/expense memos, receipts, attendees and spend limits. No reimbursement creation or expense approval through MCP.", + "evidence": [ + "https://www.brex.com/support/using-brex-in-ai-apps", + "https://api.brex.com/.well-known/oauth-protected-resource/mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "browser-use-cloud", + "method": "cloud-v4", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Create/stop browser sessions and run browser tasks under the API key.", + "evidence": [ + "https://docs.browser-use.com/cloud/api-v4-overview" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Create/stop browser sessions and run browser tasks under the API key. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + }, + { + "app": "candid", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "openid", + "profile", + "email" + ], + "capability": "read", + "supportedActions": "None: nonprofit/funder discovery and research.", + "evidence": [ + "https://learning.candid.org/getting-started-with-the-candid-mcp-connector/375441", + "https://mcp.candid.org/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "clickhouse", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "mcp:access", + "clickstack:access", + "openid", + "profile", + "email" + ], + "capability": "provider-controlled", + "supportedActions": "ClickStack dashboards, saved searches and alerts; selected service access applies.", + "evidence": [ + "https://clickhouse.com/blog/announcing-managed-clickstack-mcp-server", + "https://mcp.clickhouse.cloud/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "cloudflare", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "user:read", + "account:read" + ], + "capability": "provider-controlled", + "supportedActions": "Cloudflare API actions selected at provider consent or on the API token. Identity scopes alone do not grant account writes.", + "evidence": [ + "https://developers.cloudflare.com/agents/model-context-protocol/cloudflare/servers-for-cloudflare/", + "https://mcp.cloudflare.com/.well-known/oauth-protected-resource/mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "cloudflare", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Cloudflare API actions selected at provider consent or on the API token. Identity scopes alone do not grant account writes.", + "evidence": [ + "https://developers.cloudflare.com/agents/model-context-protocol/cloudflare/servers-for-cloudflare/", + "https://mcp.cloudflare.com/.well-known/oauth-protected-resource/mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Cloudflare API actions selected at provider consent or on the API token. Identity scopes alone do not grant account writes. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + }, + { + "app": "cloudinary", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "openid", + "profile", + "email", + "offline_access", + "asset_management", + "upload", + "media_generation" + ], + "capability": "provider-controlled", + "supportedActions": "Upload, manage and generate media in the authorized product environment.", + "evidence": [ + "https://cloudinary.com/documentation/cloudinary_llm_mcp", + "https://asset-management.mcp.cloudinary.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "coda", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "mcp:all" + ], + "capability": "write", + "supportedActions": "Modify documents/tables permitted by the account.", + "evidence": [ + "https://help.coda.io/hc/en-us/articles/44722769665549-Security-recommendations-for-the-Coda-MCP", + "https://coda.io/.well-known/oauth-protected-resource/apis/mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "coda", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Modify documents/tables permitted by the account.", + "evidence": [ + "https://help.coda.io/hc/en-us/articles/44722769665549-Security-recommendations-for-the-Coda-MCP", + "https://coda.io/.well-known/oauth-protected-resource/apis/mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Modify documents/tables permitted by the account. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + }, + { + "app": "cognee", + "method": "cloud-local", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Add/process knowledge and manage authorized datasets.", + "evidence": [ + "https://docs.cognee.ai/cognee-cloud/connections/cloud-mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Add/process knowledge and manage authorized datasets. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + }, + { + "app": "composio", + "method": "mcp", + "auth": "none", + "policy": "endpoint", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Actions depend on the configured toolkits and upstream connected accounts.", + "evidence": [ + "https://docs.composio.dev/docs/composio-connect" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "context7", + "method": "mcp", + "auth": "none", + "policy": "endpoint", + "requestedScopes": [], + "capability": "read", + "supportedActions": "None: library documentation retrieval.", + "evidence": [ + "https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." + }, + { + "app": "egnyte", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "provider-default", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Account-dependent file operations; live write verification outstanding.", + "evidence": [ + "https://developers.egnyte.com/docs/Remote_MCP_Server", + "https://mcp-server.egnyte.com/.well-known/oauth-protected-resource", + "https://mcp-oauth.egnyte.com/.well-known/oauth-authorization-server/egnyte-connect" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "providerDefaultReason": "The hosted authorization server selects the Egnyte tenant/account permissions. Neither MCP nor authorization-server metadata advertises request scopes. Documentation rendered no readable body during this review; tenant-bound write proof remains required." + }, + { + "app": "embat", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "email", + "offline_access", + "openid", + "profile" + ], + "capability": "provider-controlled", + "supportedActions": "Provider/account dependent; published metadata is identity-only, so no write guarantee.", + "evidence": [ + "https://tellme.embat.io/.well-known/oauth-protected-resource/mcp", + "https://complete-book-76.authkit.app/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." + }, + { + "app": "executor", + "method": "mcp", + "auth": "none", + "policy": "endpoint", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Actions depend on the custom MCP endpoint and configured services.", + "evidence": [ + "https://executor.sh/docs/mcp-proxy" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "fireflies", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "email", + "profile" + ], + "capability": "provider-controlled", + "supportedActions": "Share/revoke meeting access, rename/move meetings and create soundbites where the authenticated owner or team-admin role permits. The MCP resource requests profile/email, not separate mutation scopes.", + "evidence": [ + "https://docs.fireflies.ai/getting-started/mcp-configuration", + "https://api.fireflies.ai/.well-known/oauth-protected-resource", + "https://docs.fireflies.ai/mcp-tools/overview" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "fireflies", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Share/revoke meeting access, rename/move meetings and create soundbites where the authenticated owner or team-admin role permits. The MCP resource requests profile/email, not separate mutation scopes.", + "evidence": [ + "https://docs.fireflies.ai/getting-started/mcp-configuration", + "https://api.fireflies.ai/.well-known/oauth-protected-resource", + "https://docs.fireflies.ai/mcp-tools/overview" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Use an API key for an account with write access to the meetings your agents need to share, rename, move or turn into soundbites. Paperclip cannot increase the key\u2019s permissions." + }, + { + "app": "github", + "method": "managed", + "auth": "oauth", + "policy": "provider-default", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Repository contents, issues, pull requests and other granted toolsets; selected repos and installation/PAT permissions apply.", + "evidence": [ + "https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp/" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "providerDefaultReason": "Managed github.code uses a GitHub App installation and selected repositories. Installation permissions, not OAuth scope strings, grant code and pull-request writes; retain the managed profile.", + "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." + }, + { + "app": "github", + "method": "mcp-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Repository contents, issues, pull requests and other granted toolsets; selected repos and installation/PAT permissions apply.", + "evidence": [ + "https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp/" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Repository contents, issues, pull requests and other granted toolsets; selected repos and installation/PAT permissions apply. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions.", + "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." + }, + { + "app": "gmail", + "method": "paperclip-read", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/gmail.readonly" + ], + "capability": "read", + "supportedActions": "Search and read messages, threads, drafts, and labels.", + "evidence": [ + "https://developers.google.com/workspace/gmail/api/reference/mcp", + "https://gmailmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "gmail", + "method": "customer-read-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/gmail.readonly" + ], + "capability": "read", + "supportedActions": "Search and read messages, threads, drafts, and labels.", + "evidence": [ + "https://developers.google.com/workspace/gmail/api/reference/mcp", + "https://gmailmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "gmail", + "method": "paperclip-draft", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.compose" + ], + "capability": "write", + "supportedActions": "Read Gmail and create drafts for review in Gmail.", + "evidence": [ + "https://developers.google.com/workspace/gmail/api/reference/mcp", + "https://gmailmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "gmail", + "method": "customer-draft-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.compose" + ], + "capability": "write", + "supportedActions": "Read Gmail and create drafts for review in Gmail.", + "evidence": [ + "https://developers.google.com/workspace/gmail/api/reference/mcp", + "https://gmailmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-calendar", + "method": "paperclip-read", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/calendar.calendarlist.readonly", + "https://www.googleapis.com/auth/calendar.events.freebusy", + "https://www.googleapis.com/auth/calendar.events.readonly" + ], + "capability": "read", + "supportedActions": "Read calendars, events, and availability.", + "evidence": [ + "https://developers.google.com/workspace/calendar/api/reference/mcp", + "https://calendarmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." + }, + { + "app": "google-calendar", + "method": "customer-read-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/calendar.calendarlist.readonly", + "https://www.googleapis.com/auth/calendar.events.freebusy", + "https://www.googleapis.com/auth/calendar.events.readonly" + ], + "capability": "read", + "supportedActions": "Read calendars, events, and availability.", + "evidence": [ + "https://developers.google.com/workspace/calendar/api/reference/mcp", + "https://calendarmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." + }, + { + "app": "google-calendar", + "method": "paperclip-write", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/calendar.calendarlist.readonly", + "https://www.googleapis.com/auth/calendar.events" + ], + "capability": "write", + "supportedActions": "Create, update, respond to, and delete events.", + "evidence": [ + "https://developers.google.com/workspace/calendar/api/reference/mcp", + "https://calendarmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." + }, + { + "app": "google-calendar", + "method": "customer-write-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/calendar.calendarlist.readonly", + "https://www.googleapis.com/auth/calendar.events" + ], + "capability": "write", + "supportedActions": "Create, update, respond to, and delete events.", + "evidence": [ + "https://developers.google.com/workspace/calendar/api/reference/mcp", + "https://calendarmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." + }, + { + "app": "google-chat", + "method": "paperclip-read", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/chat.spaces.readonly", + "https://www.googleapis.com/auth/chat.messages.readonly" + ], + "capability": "read", + "supportedActions": "Search conversations and read messages.", + "evidence": [ + "https://developers.google.com/workspace/chat/api/reference/mcp", + "https://chatmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-chat", + "method": "customer-read-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/chat.spaces.readonly", + "https://www.googleapis.com/auth/chat.messages.readonly" + ], + "capability": "read", + "supportedActions": "Search conversations and read messages.", + "evidence": [ + "https://developers.google.com/workspace/chat/api/reference/mcp", + "https://chatmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-chat", + "method": "paperclip-write", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/chat.spaces.readonly", + "https://www.googleapis.com/auth/chat.messages.readonly", + "https://www.googleapis.com/auth/chat.messages.create" + ], + "capability": "write", + "supportedActions": "Read Chat and send messages.", + "evidence": [ + "https://developers.google.com/workspace/chat/api/reference/mcp", + "https://chatmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-chat", + "method": "customer-write-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/chat.spaces.readonly", + "https://www.googleapis.com/auth/chat.messages.readonly", + "https://www.googleapis.com/auth/chat.messages.create" + ], + "capability": "write", + "supportedActions": "Read Chat and send messages.", + "evidence": [ + "https://developers.google.com/workspace/chat/api/reference/mcp", + "https://chatmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-docs", + "method": "paperclip-read", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/documents.readonly" + ], + "capability": "read", + "supportedActions": "Read document text and structure.", + "evidence": [ + "https://developers.google.com/workspace/docs/api/reference/mcp", + "https://docsmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-docs", + "method": "customer-read-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/documents.readonly" + ], + "capability": "read", + "supportedActions": "Read document text and structure.", + "evidence": [ + "https://developers.google.com/workspace/docs/api/reference/mcp", + "https://docsmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-docs", + "method": "paperclip-write", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/documents" + ], + "capability": "write", + "supportedActions": "Read and update documents.", + "evidence": [ + "https://developers.google.com/workspace/docs/api/reference/mcp", + "https://docsmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-docs", + "method": "customer-write-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/documents" + ], + "capability": "write", + "supportedActions": "Read and update documents.", + "evidence": [ + "https://developers.google.com/workspace/docs/api/reference/mcp", + "https://docsmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-drive", + "method": "paperclip-read", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/drive.readonly" + ], + "capability": "read", + "supportedActions": "Search and read files and metadata.", + "evidence": [ + "https://developers.google.com/workspace/drive/api/reference/mcp", + "https://drivemcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-drive", + "method": "customer-read-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/drive.readonly" + ], + "capability": "read", + "supportedActions": "Search and read files and metadata.", + "evidence": [ + "https://developers.google.com/workspace/drive/api/reference/mcp", + "https://drivemcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-drive", + "method": "paperclip-write", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive.file" + ], + "capability": "write", + "supportedActions": "Read files and create or copy files.", + "evidence": [ + "https://developers.google.com/workspace/drive/api/reference/mcp", + "https://drivemcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-drive", + "method": "customer-write-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive.file" + ], + "capability": "write", + "supportedActions": "Read files and create or copy files.", + "evidence": [ + "https://developers.google.com/workspace/drive/api/reference/mcp", + "https://drivemcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-people", + "method": "paperclip-read", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/directory.readonly", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/contacts.readonly" + ], + "capability": "read", + "supportedActions": "Search contacts, directory people, and your profile.", + "evidence": [ + "https://developers.google.com/people/api/mcp", + "https://people.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-people", + "method": "customer-read-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/directory.readonly", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/contacts.readonly" + ], + "capability": "read", + "supportedActions": "Search contacts, directory people, and your profile.", + "evidence": [ + "https://developers.google.com/people/api/mcp", + "https://people.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-sheets", + "method": "paperclip-read", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/spreadsheets.readonly" + ], + "capability": "read", + "supportedActions": "Read spreadsheet values and structure.", + "evidence": [ + "https://developers.google.com/workspace/sheets/api/reference/mcp", + "https://sheetsmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-sheets", + "method": "customer-read-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/spreadsheets.readonly" + ], + "capability": "read", + "supportedActions": "Read spreadsheet values and structure.", + "evidence": [ + "https://developers.google.com/workspace/sheets/api/reference/mcp", + "https://sheetsmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-sheets", + "method": "paperclip-write", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/spreadsheets" + ], + "capability": "write", + "supportedActions": "Read and update spreadsheet values, formulas, and dimensions.", + "evidence": [ + "https://developers.google.com/workspace/sheets/api/reference/mcp", + "https://sheetsmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-sheets", + "method": "customer-write-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/spreadsheets" + ], + "capability": "write", + "supportedActions": "Read and update spreadsheet values, formulas, and dimensions.", + "evidence": [ + "https://developers.google.com/workspace/sheets/api/reference/mcp", + "https://sheetsmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-sheets", + "method": "local", + "auth": "none", + "policy": "endpoint", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Share only named spreadsheets with the Paperclip robot account.", + "evidence": [ + "https://developers.google.com/workspace/sheets/api/reference/mcp", + "https://sheetsmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-slides", + "method": "paperclip-read", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/presentations.readonly" + ], + "capability": "read", + "supportedActions": "Read presentation slides and content.", + "evidence": [ + "https://developers.google.com/workspace/slides/api/reference/mcp", + "https://slidesmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-slides", + "method": "customer-read-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/presentations.readonly" + ], + "capability": "read", + "supportedActions": "Read presentation slides and content.", + "evidence": [ + "https://developers.google.com/workspace/slides/api/reference/mcp", + "https://slidesmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-slides", + "method": "paperclip-write", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/presentations" + ], + "capability": "write", + "supportedActions": "Read and update presentations.", + "evidence": [ + "https://developers.google.com/workspace/slides/api/reference/mcp", + "https://slidesmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-slides", + "method": "customer-write-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/presentations" + ], + "capability": "write", + "supportedActions": "Read and update presentations.", + "evidence": [ + "https://developers.google.com/workspace/slides/api/reference/mcp", + "https://slidesmcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-workspace-search", + "method": "paperclip-read", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/chat.messages.readonly" + ], + "capability": "read", + "supportedActions": "Search Gmail, Drive, Calendar, and Chat without write access.", + "evidence": [ + "https://developers.google.com/workspace/guides/universal-search-mcp", + "https://workspacemcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "google-workspace-search", + "method": "customer-read-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/chat.messages.readonly" + ], + "capability": "read", + "supportedActions": "Search Gmail, Drive, Calendar, and Chat without write access.", + "evidence": [ + "https://developers.google.com/workspace/guides/universal-search-mcp", + "https://workspacemcp.googleapis.com/.well-known/oauth-protected-resource/mcp/v1" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "honcho", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Create peers/sessions and save memory under the API key project.", + "evidence": [ + "https://honcho.dev/docs/v3/guides/integrations/mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Create peers/sessions and save memory under the API key project. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + }, + { + "app": "hugging-face", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "read-mcp", + "read-repos", + "contribute-repos", + "jobs" + ], + "capability": "write", + "supportedActions": "Create/contribute repositories and schedule/run jobs; users must enable those tools in HF MCP settings. Paid compute still requires an eligible account.", + "evidence": [ + "https://huggingface.co/docs/hub/en/agents-mcp", + "https://huggingface.co/docs/hub/agents-mcp", + "https://huggingface.co/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "jira", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "read:me", + "read:account", + "offline_access", + "email", + "read:jira-work", + "write:jira-work", + "search:confluence", + "read:confluence-user", + "read:page:confluence", + "write:page:confluence", + "read:comment:confluence", + "write:comment:confluence", + "read:space:confluence", + "read:hierarchical-content:confluence", + "write:component:compass", + "read:component:compass", + "read:scorecard:compass", + "write:scorecard:compass", + "read:event:compass", + "read:metric:compass", + "read:all:twg", + "write:all:twg" + ], + "capability": "write", + "supportedActions": "Jira issues, Confluence pages/comments and Compass components/scorecards. Admin policy and consent toolset choices apply.", + "evidence": [ + "https://support.atlassian.com/atlassian-rovo-mcp-server/docs/getting-started-with-the-atlassian-remote-mcp-server/", + "https://mcp.atlassian.com/.well-known/oauth-protected-resource/v1/mcp/authv2" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "kernel", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "openid" + ], + "capability": "provider-controlled", + "supportedActions": "Launch/manage browsers, profiles, apps and browser automation.", + "evidence": [ + "https://www.kernel.sh/docs/reference/mcp-server/", + "https://mcp.onkernel.com/.well-known/oauth-protected-resource/mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "kernel", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Launch/manage browsers, profiles, apps and browser automation.", + "evidence": [ + "https://www.kernel.sh/docs/reference/mcp-server/", + "https://mcp.onkernel.com/.well-known/oauth-protected-resource/mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Launch/manage browsers, profiles, apps and browser automation. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + }, + { + "app": "linear", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "read", + "write" + ], + "capability": "write", + "supportedActions": "Create/update issues and other authorized workspace resources.", + "evidence": [ + "https://mcp.linear.app/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." + }, + { + "app": "local-falcon", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "api", + "offline_access" + ], + "capability": "provider-controlled", + "supportedActions": "Account-authorized scans/campaigns; subscription credits apply.", + "evidence": [ + "https://docs.localfalcon.com/", + "https://mcp.localfalcon.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "make", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "provider-default", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Run selected scenarios; paid plans additionally manage scenarios, data stores and teams.", + "evidence": [ + "https://developers.make.com/mcp-server", + "https://mcp.make.com/.well-known/oauth-protected-resource", + "https://www.make.com/.well-known/oauth-authorization-server/mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "providerDefaultReason": "Make documents selecting scopes and scenarios during its hosted connection flow. Management tools require a paid plan. Do not replace that resource selection with invented MCP client scopes." + }, + { + "app": "manufact", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "openid", + "profile", + "email", + "offline_access" + ], + "capability": "provider-controlled", + "supportedActions": "Deploy/redeploy/stop servers and edit server configuration under the signed-in role.", + "evidence": [ + "https://docs.manufact.com/mcp", + "https://mcp.manufact.com/.well-known/oauth-protected-resource/mcp", + "https://cloud.manufact.com/.well-known/oauth-authorization-server/api/auth" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "mem0", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Add/update/delete memories under the API key account.", + "evidence": [ + "https://docs.mem0.ai/platform/mem0-mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Add/update/delete memories under the API key account. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + }, + { + "app": "miro", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "boards:read", + "boards:write", + "openid", + "email" + ], + "capability": "write", + "supportedActions": "Create board content/diagrams and act on comments; board/team ACLs apply.", + "evidence": [ + "https://help.miro.com/hc/en-us/articles/31625301583890-How-to-enable-Miro-s-MCP-Server-user-guide", + "https://mcp.miro.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "mixpanel", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "projects", + "analysis", + "events", + "insights", + "segmentation", + "retention", + "data:read", + "funnels", + "flows", + "data_definitions", + "alerts", + "annotations", + "bookmarks", + "business_context", + "cohorts", + "context", + "custom_alerts", + "custom_events", + "custom_properties", + "dashboard_reports", + "experiments", + "feature_flags", + "metrics", + "user_details" + ], + "capability": "provider-controlled", + "supportedActions": "Metadata, annotations, cohorts and enabled management tools; project role still governs each action.", + "evidence": [ + "https://mixpanel.com/blog/mixpanel-mcp-server/", + "https://mcp.mixpanel.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "netlify", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "offline_access", + "read", + "write" + ], + "capability": "write", + "supportedActions": "Deploy/manage sites via the account; no Claude-specific scope is required for Paperclip.", + "evidence": [ + "https://docs.netlify.com/build/build-with-ai/agent-setup-guides/agent-setup-overview/", + "https://netlify-mcp.netlify.app/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "notion", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "default" + ], + "capability": "write", + "supportedActions": "Create/update pages and databases shared with the authenticated account.", + "evidence": [ + "https://developers.notion.com/guides/mcp/build-mcp-client", + "https://mcp.notion.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "oauth-generic", + "method": "oauth", + "auth": "oauth", + "policy": "provider-default", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Actions and scope requirements depend on the operator-supplied provider.", + "evidence": [ + "https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "providerDefaultReason": "Operator-defined OAuth endpoints have no provider-specific reviewed scope list. Request only the scopes supplied by the operator; this template is not a curated provider allowlist.", + "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." + }, + { + "app": "oreilly", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "openid", + "offline_access" + ], + "capability": "read", + "supportedActions": "None: content discovery and retrieval.", + "evidence": [ + "https://learning.oreilly.com/apidocs/mcp/content/", + "https://api.oreilly.com/.well-known/oauth-protected-resource/api/content-discovery/v1/mcp/" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "oreilly", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "read", + "supportedActions": "None: content discovery and retrieval.", + "evidence": [ + "https://learning.oreilly.com/apidocs/mcp/content/", + "https://api.oreilly.com/.well-known/oauth-protected-resource/api/content-discovery/v1/mcp/" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "None: content discovery and retrieval. A read-only key is sufficient." + }, + { + "app": "pagerduty", + "method": "mcp-api-key-us", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Incident and on-call changes require a full-access API key and role access.", + "evidence": [ + "https://support.pagerduty.com/main/docs/pagerduty-mcp-server" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Incident and on-call changes require a full-access API key and role access. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + }, + { + "app": "pagerduty", + "method": "mcp-api-key-eu", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Incident and on-call changes require a full-access API key and role access.", + "evidence": [ + "https://support.pagerduty.com/main/docs/pagerduty-mcp-server" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Incident and on-call changes require a full-access API key and role access. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + }, + { + "app": "planetscale", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "provider-default", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "SQL writes and database actions only when selected at provider consent; insights-only remains read only.", + "evidence": [ + "https://planetscale.com/docs/connect/mcp", + "https://mcp.pscale.dev/.well-known/oauth-protected-resource/mcp/planetscale", + "https://mcp.pscale.dev/.well-known/oauth-protected-resource/mcp/planetscale-insights-only", + "https://mcp.pscale.dev/.well-known/oauth-authorization-server/mcp/planetscale-insights-only", + "https://api.planetscale.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "providerDefaultReason": "PlanetScale documents choosing organizations, databases and read/write permission at authorization. Its general authorization server advertises unrelated organization administration too; retain the provider consent selection, and the separate insights-only endpoint." + }, + { + "app": "planetscale", + "method": "mcp-insights-only", + "auth": "oauth", + "policy": "provider-default", + "requestedScopes": [], + "capability": "read", + "supportedActions": "SQL writes and database actions only when selected at provider consent; insights-only remains read only.", + "evidence": [ + "https://planetscale.com/docs/connect/mcp", + "https://mcp.pscale.dev/.well-known/oauth-protected-resource/mcp/planetscale", + "https://mcp.pscale.dev/.well-known/oauth-protected-resource/mcp/planetscale-insights-only", + "https://mcp.pscale.dev/.well-known/oauth-authorization-server/mcp/planetscale-insights-only", + "https://api.planetscale.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "providerDefaultReason": "PlanetScale documents choosing organizations, databases and read/write permission at authorization. Its general authorization server advertises unrelated organization administration too; retain the provider consent selection, and the separate insights-only endpoint." + }, + { + "app": "posthog", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "provider-default", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Analytics objects, feature flags, experiments, error triage and other enabled product actions. readonly/feature/tool filters remain enforced.", + "evidence": [ + "https://posthog.com/docs/model-context-protocol", + "https://mcp.posthog.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "providerDefaultReason": "PostHog documents the no-scope MCP setup as read/write, with optional readonly and feature filters. The authorization server advertises account administration unrelated to many tools; retain its MCP consent defaults and the existing explicit filters." + }, + { + "app": "posthog", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Analytics objects, feature flags, experiments, error triage and other enabled product actions. readonly/feature/tool filters remain enforced.", + "evidence": [ + "https://posthog.com/docs/model-context-protocol", + "https://mcp.posthog.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Analytics objects, feature flags, experiments, error triage and other enabled product actions. readonly/feature/tool filters remain enforced. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + }, + { + "app": "postman", + "method": "mcp-oauth-minimal", + "auth": "oauth", + "policy": "provider-default", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role.", + "evidence": [ + "https://learning.postman.com/latest-v-12/docs/reference/postman-api/postman-mcp-server/postman-mcp-remote-server", + "https://mcp.postman.com/.well-known/oauth-protected-resource", + "https://mcp.postman.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "providerDefaultReason": "The official remote MCP setup uses browser sign-in and endpoint-selected minimal/code/full tool catalogs. Its protected-resource and authorization-server metadata publish no scope set; account/workspace rights govern writes." + }, + { + "app": "postman", + "method": "mcp-oauth-code", + "auth": "oauth", + "policy": "provider-default", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role.", + "evidence": [ + "https://learning.postman.com/latest-v-12/docs/reference/postman-api/postman-mcp-server/postman-mcp-remote-server", + "https://mcp.postman.com/.well-known/oauth-protected-resource", + "https://mcp.postman.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "providerDefaultReason": "The official remote MCP setup uses browser sign-in and endpoint-selected minimal/code/full tool catalogs. Its protected-resource and authorization-server metadata publish no scope set; account/workspace rights govern writes." + }, + { + "app": "postman", + "method": "mcp-oauth-full", + "auth": "oauth", + "policy": "provider-default", + "requestedScopes": [], + "capability": "write", + "supportedActions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role.", + "evidence": [ + "https://learning.postman.com/latest-v-12/docs/reference/postman-api/postman-mcp-server/postman-mcp-remote-server", + "https://mcp.postman.com/.well-known/oauth-protected-resource", + "https://mcp.postman.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "providerDefaultReason": "The official remote MCP setup uses browser sign-in and endpoint-selected minimal/code/full tool catalogs. Its protected-resource and authorization-server metadata publish no scope set; account/workspace rights govern writes." + }, + { + "app": "postman", + "method": "mcp-eu-key-minimal", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role.", + "evidence": [ + "https://learning.postman.com/latest-v-12/docs/reference/postman-api/postman-mcp-server/postman-mcp-remote-server", + "https://mcp.postman.com/.well-known/oauth-protected-resource", + "https://mcp.postman.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + }, + { + "app": "postman", + "method": "mcp-eu-key-code", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role.", + "evidence": [ + "https://learning.postman.com/latest-v-12/docs/reference/postman-api/postman-mcp-server/postman-mcp-remote-server", + "https://mcp.postman.com/.well-known/oauth-protected-resource", + "https://mcp.postman.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + }, + { + "app": "postman", + "method": "mcp-eu-key-full", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "write", + "supportedActions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role.", + "evidence": [ + "https://learning.postman.com/latest-v-12/docs/reference/postman-api/postman-mcp-server/postman-mcp-remote-server", + "https://mcp.postman.com/.well-known/oauth-protected-resource", + "https://mcp.postman.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + }, + { + "app": "railway", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "openid", + "offline_access", + "workspace:member" + ], + "capability": "provider-controlled", + "supportedActions": "Project/service/deployment operations in authorized workspaces; workspace:member and account role bound access.", + "evidence": [ + "https://docs.railway.com/ai/mcp-server", + "https://mcp.railway.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "razorpay", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "provider-default", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Provider-authorized payment/account tools; financial policy gates remain. Live verification outstanding.", + "evidence": [ + "https://razorpay.com/docs/mcp-server/oauth/", + "https://mcp.razorpay.com/.well-known/oauth-protected-resource", + "https://mcp.razorpay.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "providerDefaultReason": "Neither protected-resource nor authorization-server metadata advertises scopes. Keep hosted provider consent; the previous official OAuth documentation URL returned 404 during review. Account-bound writes need live confirmation.", + "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." + }, + { + "app": "razorpay", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Provider-authorized payment/account tools; financial policy gates remain. Live verification outstanding.", + "evidence": [ + "https://razorpay.com/docs/mcp-server/oauth/", + "https://mcp.razorpay.com/.well-known/oauth-protected-resource", + "https://mcp.razorpay.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Provider-authorized payment/account tools; financial policy gates remain. Live verification outstanding. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions.", + "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." + }, + { + "app": "resend", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "full_access" + ], + "capability": "write", + "supportedActions": "Send email and manage domains/templates/account resources. full_access covers the MCP management tools as well as sending.", + "evidence": [ + "https://resend.com/changelog/remote-mcp-server", + "https://mcp.resend.com/.well-known/oauth-protected-resource", + "https://api.resend.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "sanity", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "global" + ], + "capability": "write", + "supportedActions": "Edit content permitted by project role; global is MCP authorization, not a bypass of project ACLs.", + "evidence": [ + "https://www.sanity.io/docs/ai/mcp-server", + "https://mcp.sanity.io/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "sanity", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Edit content permitted by project role; global is MCP authorization, not a bypass of project ACLs.", + "evidence": [ + "https://www.sanity.io/docs/ai/mcp-server", + "https://mcp.sanity.io/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Edit content permitted by project role; global is MCP authorization, not a bypass of project ACLs. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + }, + { + "app": "sentry", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "org:read", + "project:write", + "team:write", + "event:write", + "alerts:write" + ], + "capability": "write", + "supportedActions": "Manage projects/teams, triage issues and edit alerts under the authorized organization role.", + "evidence": [ + "https://mcp.sentry.dev/.well-known/oauth-authorization-server", + "https://mcp.sentry.dev/.well-known/oauth-protected-resource/mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." + }, + { + "app": "shopify", + "method": "ucp-commerce", + "auth": "none", + "policy": "endpoint", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Storefront cart operations; checkout/purchase and account privileges remain provider-controlled.", + "evidence": [ + "https://shopify.dev/docs/apps/build/storefront-mcp/servers/storefront" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "shopify", + "method": "storefront-mcp", + "auth": "none", + "policy": "endpoint", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Storefront cart operations; checkout/purchase and account privileges remain provider-controlled.", + "evidence": [ + "https://shopify.dev/docs/apps/build/storefront-mcp/servers/storefront" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "similarweb", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "read", + "supportedActions": "None: analytics retrieval; subscription entitlements apply.", + "evidence": [ + "https://developers.similarweb.com/docs/similarweb-mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "None: analytics retrieval; subscription entitlements apply. A read-only key is sufficient." + }, + { + "app": "slack", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "canvases:read", + "canvases:write", + "channels:history", + "channels:read", + "channels:write", + "chat:write", + "emoji:read", + "files:read", + "files:write", + "groups:history", + "groups:read", + "groups:write", + "im:history", + "im:read", + "im:write", + "lists:read", + "lists:write", + "mpim:history", + "mpim:read", + "mpim:write", + "reactions:read", + "reactions:write", + "search:read.files", + "search:read.im", + "search:read.mpim", + "search:read.private", + "search:read.public", + "search:read.users", + "users:read", + "users:read.email" + ], + "capability": "write", + "supportedActions": "Send/schedule messages, create conversations, react, edit canvases/lists and upload files. Published internal app/Marketplace and admin approval requirements apply.", + "evidence": [ + "https://docs.slack.dev/ai/slack-mcp-server/", + "https://mcp.slack.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." + }, + { + "app": "stripe", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "mcp" + ], + "capability": "provider-controlled", + "supportedActions": "Account- and sandbox-specific writes selected during Stripe consent; financial approvals and restricted-key permissions remain.", + "evidence": [ + "https://docs.stripe.com/mcp", + "https://mcp.stripe.com/.well-known/oauth-protected-resource", + "https://access.stripe.com/.well-known/oauth-authorization-server/mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "stripe", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Account- and sandbox-specific writes selected during Stripe consent; financial approvals and restricted-key permissions remain.", + "evidence": [ + "https://docs.stripe.com/mcp", + "https://mcp.stripe.com/.well-known/oauth-protected-resource", + "https://access.stripe.com/.well-known/oauth-authorization-server/mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Account- and sandbox-specific writes selected during Stripe consent; financial approvals and restricted-key permissions remain. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + }, + { + "app": "supabase", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "organizations:read", + "projects:read", + "projects:write", + "database:write", + "database:read", + "analytics:read", + "secrets:read", + "edge_functions:read", + "edge_functions:write", + "environment:read", + "environment:write", + "storage:read", + "storage:write" + ], + "capability": "write", + "supportedActions": "Project/database/environment/storage changes and Edge Function deployment. Project selection and optional read-only configuration remain authoritative.", + "evidence": [ + "https://supabase.com/docs/guides/ai-tools/mcp", + "https://mcp.supabase.com/.well-known/oauth-protected-resource/mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "supabase", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Project/database/environment/storage changes and Edge Function deployment. Project selection and optional read-only configuration remain authoritative.", + "evidence": [ + "https://supabase.com/docs/guides/ai-tools/mcp", + "https://mcp.supabase.com/.well-known/oauth-protected-resource/mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "Project/database/environment/storage changes and Edge Function deployment. Project selection and optional read-only configuration remain authoritative. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key\u2019s permissions." + }, + { + "app": "supermemory", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "openid", + "profile", + "email", + "offline_access" + ], + "capability": "provider-controlled", + "supportedActions": "Save memories/documents in the workspace selected at provider consent; read/write choice is provider-controlled.", + "evidence": [ + "https://supermemory.ai/docs/supermemory-mcp/mcp", + "https://mcp.supermemory.ai/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "ticket-tailor", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "provider-default", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Box-office operations allowed by the API key chosen at provider consent.", + "evidence": [ + "https://developers.tickettailor.com/docs/mcp/authentication/" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "providerDefaultReason": "Hosted authorization controls box-office API-key permissions; do not treat OAuth sign-in as increasing the underlying key permissions. This method requires account-bound proof." + }, + { + "app": "ticktick", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "tasks:write", + "tasks:read" + ], + "capability": "write", + "supportedActions": "Create/update tasks in the authenticated account.", + "evidence": [ + "https://help.ticktick.com/articles/7438129581631995904", + "https://mcp.ticktick.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "todoist", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "data:read_write" + ], + "capability": "write", + "supportedActions": "Create/update tasks and projects in the authenticated account.", + "evidence": [ + "https://developer.todoist.com/", + "https://ai.todoist.net/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "vercel", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "openid" + ], + "capability": "provider-controlled", + "supportedActions": "Account/team-authorized project and deployment actions; identity scope is not an admin permission.", + "evidence": [ + "https://mcp.vercel.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "evidenceLimit": "Provider documentation was unavailable or not readable during this review; metadata/registered source only. Account-bound proof remains outstanding." + }, + { + "app": "webflow", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "provider-default", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Build/edit sites and CMS content for explicitly authorized sites.", + "evidence": [ + "https://developers.webflow.com/mcp/reference/getting-started", + "https://mcp.webflow.com/.well-known/oauth-protected-resource", + "https://mcp.webflow.com/.well-known/oauth-authorization-server" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "providerDefaultReason": "The official MCP setup installs the Bridge App and asks users to authorize sites. Neither protected-resource nor authorization-server metadata publishes a request-scope list; the installed app and site selection govern writes." + }, + { + "app": "wix", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "offline_access" + ], + "capability": "provider-controlled", + "supportedActions": "Site editing and business operations under the signed-in Wix role.", + "evidence": [ + "https://www.wix.com/studio/developers/mcp-server", + "https://mcp.wix.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "xero", + "method": "mcp-own-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "openid", + "profile", + "email", + "offline_access", + "accounting.settings", + "accounting.invoices.read", + "accounting.reports.aged.read", + "accounting.reports.balancesheet.read", + "accounting.reports.profitandloss.read" + ], + "capability": "provider-controlled", + "supportedActions": "Invoice/report reads and settings access only in the current MCP resource profile; no invoice writes are advertised.", + "evidence": [ + "https://developer.xero.com/ai", + "https://mcp.xero.com/.well-known/oauth-protected-resource/mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "youcom", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "Tools", + "offline_access" + ], + "capability": "read", + "supportedActions": "None: search, contents and research tools (usage may be billed).", + "evidence": [ + "https://you.com/docs/build-with-agents/mcp-server", + "https://api.you.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "youcom", + "method": "mcp-api-key", + "auth": "api_key", + "policy": "provider-key", + "requestedScopes": [], + "capability": "read", + "supportedActions": "None: search, contents and research tools (usage may be billed).", + "evidence": [ + "https://you.com/docs/build-with-agents/mcp-server", + "https://api.you.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run", + "keyPermissions": "None: search, contents and research tools (usage may be billed). A read-only key is sufficient." + }, + { + "app": "youcom", + "method": "mcp-free", + "auth": "none", + "policy": "endpoint", + "requestedScopes": [], + "capability": "read", + "supportedActions": "None: search, contents and research tools (usage may be billed).", + "evidence": [ + "https://you.com/docs/build-with-agents/mcp-server", + "https://api.you.com/.well-known/oauth-protected-resource" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "zapier", + "method": "generated-url", + "auth": "none", + "policy": "endpoint", + "requestedScopes": [], + "capability": "provider-controlled", + "supportedActions": "Run only the actions configured by the user in the generated Zapier MCP endpoint; a URL cannot add actions or permissions.", + "evidence": [ + "https://docs.zapier.com/mcp/quickstart" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + }, + { + "app": "zep", + "method": "mcp-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "graph:read", + "graph:write" + ], + "capability": "write", + "supportedActions": "Write memory graphs authorized for the signed-in identity.", + "evidence": [ + "https://help.getzep.com/memory-mcp-server", + "https://api.getzep.com/.well-known/oauth-protected-resource/mcp" + ], + "reviewedAt": "2026-09-30", + "liveProof": "not-run" + } + ] +} diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index fea1cde846..0baaf0a5a4 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -592,6 +592,7 @@ describe("AppDefinition catalog", () => { expect(notion?.redirectConstraints).toBe("https-or-loopback-http"); expect(notion?.methods[0]?.defaults).toEqual({ serverUrl: "https://mcp.notion.com/mcp", + scopesHint: ["default"], }); }); it("preserves required Linear OAuth scopes", () => @@ -599,11 +600,11 @@ describe("AppDefinition catalog", () => { APP_DEFINITIONS.find((app) => app.slug === "linear")?.methods[0]?.defaults ?.scopesHint, ).toEqual(["read", "write"])); - it("requests only Hugging Face's MCP read scope", () => + it("requests Hugging Face MCP read, contribution and job scopes", () => expect( APP_DEFINITIONS.find((app) => app.slug === "hugging-face")?.methods[0] ?.defaults?.scopesHint, - ).toEqual(["read-mcp"])); + ).toEqual(["read-mcp", "read-repos", "contribute-repos", "jobs"])); it("defaults every new connection action to allowed", () => { for (const app of APP_DEFINITIONS) for (const method of app.methods) @@ -638,7 +639,7 @@ describe("AppDefinition catalog", () => { ].includes(candidate.key), ), )?.key, - ).toBe("paperclip-read"); + ).toBe("customer-draft-oauth"); expect( getRecommendedConnectionMethod( gmail.methods.filter( @@ -1006,3 +1007,39 @@ describe("Railway provider", () => { expect(JSON.stringify(app.methods)).toContain("Live Railway qualification is pending"); }); }); + + +describe("tool method permission review", () => { + const audit = JSON.parse(fs.readFileSync(new URL("../../../doc/connections/tool-method-permission-reviews.json", import.meta.url), "utf8")) as { + methods: { app: string; method: string; auth: string; policy: string; requestedScopes: string[]; providerDefaultReason?: string; supportedActions: string; evidence: string[]; reviewedAt: string }[]; + }; + const methods = APP_DEFINITIONS.flatMap((app) => app.methods + .filter((method) => method.purpose !== "channel" && method.purpose !== "ai") + .map((method) => ({ app, method }))); + it("requires an explicit review for every tool method, including documented scope omissions", () => { + expect(new Set(audit.methods.map((review) => `${review.app}/${review.method}`)).size).toBe(audit.methods.length); + expect(audit.methods).toHaveLength(methods.length); + for (const { app, method } of methods) { + const review = audit.methods.find((entry) => entry.app === app.slug && entry.method === method.key); + expect(review, `${app.slug}/${method.key}`).toBeDefined(); + expect(review!.auth).toBe(method.auth); + expect(review!.supportedActions.length).toBeGreaterThan(15); + expect(review!.evidence.length).toBeGreaterThan(0); + if (method.auth === "oauth") { + expect(method.defaults?.scopesHint ?? []).toEqual(review!.requestedScopes); + if (!review!.requestedScopes.length) { + expect(review!.policy).toBe("provider-default"); + expect(review!.providerDefaultReason!.length).toBeGreaterThan(30); + } else expect(review!.policy).toBe("explicit"); + } + } + }); + it("requests Airtable record, schema and comment writes, and Hugging Face repository/job actions", () => { + expect(APP_DEFINITIONS.find((app) => app.slug === "airtable")!.methods[0]!.defaults!.scopesHint).toEqual([ + "data.records:read", "data.records:write", "schema.bases:read", "schema.bases:write", + "data.recordComments:read", "data.recordComments:write", "workspacesAndBases:read", + ]); + expect(APP_DEFINITIONS.find((app) => app.slug === "hugging-face")!.methods[0]!.defaults!.scopesHint) + .toEqual(["read-mcp", "read-repos", "contribute-repos", "jobs"]); + }); +}); diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts index 49f1bb1967..b2e5404009 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -140,13 +140,11 @@ export function getRecommendedConnectionMethod( || method.oauthStrategy === "paperclip_id_connector" ); - // When a managed pilot advertises only read access, defaulting to a - // customer-owned write method would turn the available one-click path into - // an OAuth client setup form. Capability-specific callers pass only the - // selected group, so explicit write/draft choices keep their own fallback. + // Prefer the permissions needed for agent work, then the simplest sign-in. + // Explicit read-only selections pass their own capability group here. return recommendedCapability(managedMethods) - ?? managedMethods[0] ?? recommendedCapability(methods) + ?? managedMethods[0] ?? methods[0] ?? null; } @@ -222,6 +220,13 @@ export function credentialConfigPath(field: FieldDef, method?: ConnectionMethodD return `credentials.${field.key}`; } +/** Older credential references used bare names; current references use paths. */ +export function connectionCredentialConfigPath(ref: { name: string }): string { + return /^(credentials|headers|oauth|remote)\./.test(ref.name) + ? ref.name + : `credentials.${ref.name}`; +} + export function resolveConnectionMethodServerUrl( method: ConnectionMethodDef, configValues: Record, diff --git a/packages/shared/src/app-definitions/airtable.json b/packages/shared/src/app-definitions/airtable.json index e07676ca40..ad8efa1c68 100644 --- a/packages/shared/src/app-definitions/airtable.json +++ b/packages/shared/src/app-definitions/airtable.json @@ -25,7 +25,16 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://mcp.airtable.com/mcp" + "serverUrl": "https://mcp.airtable.com/mcp", + "scopesHint": [ + "data.records:read", + "data.records:write", + "schema.bases:read", + "schema.bases:write", + "data.recordComments:read", + "data.recordComments:write", + "workspacesAndBases:read" + ] }, "guidanceMd": "Connect Airtable in the browser. An Airtable account; enterprise administrators may need to allowlist the client.", "riskTier": "S3", diff --git a/packages/shared/src/app-definitions/api-key-generic.json b/packages/shared/src/app-definitions/api-key-generic.json index 7ba7863f73..09e6d42955 100644 --- a/packages/shared/src/app-definitions/api-key-generic.json +++ b/packages/shared/src/app-definitions/api-key-generic.json @@ -30,7 +30,8 @@ "type": "password", "required": true, "placeholder": "Paste the API key", - "secret": true + "secret": true, + "helperMd": "Actions depend on the operator-supplied API and key; grant read/write on the required resources at the provider. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/asana.json b/packages/shared/src/app-definitions/asana.json index d144ead8be..8376a35288 100644 --- a/packages/shared/src/app-definitions/asana.json +++ b/packages/shared/src/app-definitions/asana.json @@ -25,7 +25,10 @@ ], "whenToUse": "Register an OAuth app with Asana, then enter its client ID and secret.", "defaults": { - "serverUrl": "https://mcp.asana.com/v2/mcp" + "serverUrl": "https://mcp.asana.com/v2/mcp", + "scopesHint": [ + "default" + ] }, "guidanceMd": "Connect Asana in the browser. Create an Asana MCP OAuth app and register Paperclip's callback URI; DCR is not supported.", "riskTier": "S3", diff --git a/packages/shared/src/app-definitions/beehiiv.json b/packages/shared/src/app-definitions/beehiiv.json index fe1ec45a38..47aec8fdfb 100644 --- a/packages/shared/src/app-definitions/beehiiv.json +++ b/packages/shared/src/app-definitions/beehiiv.json @@ -25,7 +25,11 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://mcp.beehiiv.com/mcp" + "serverUrl": "https://mcp.beehiiv.com/mcp", + "scopesHint": [ + "read", + "write" + ] }, "guidanceMd": "Connect beehiiv in the browser. A beehiiv account; the subscription plan controls available write capabilities.", "riskTier": "S3", diff --git a/packages/shared/src/app-definitions/bitly.json b/packages/shared/src/app-definitions/bitly.json index 4bd0bee7db..fe65a46c5c 100644 --- a/packages/shared/src/app-definitions/bitly.json +++ b/packages/shared/src/app-definitions/bitly.json @@ -58,7 +58,8 @@ "type": "password", "required": true, "placeholder": "Paste your Bitly API token", - "secret": true + "secret": true, + "helperMd": "Create/manage links in authorized groups. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/brex.json b/packages/shared/src/app-definitions/brex.json index f02b41a13d..08fce55d39 100644 --- a/packages/shared/src/app-definitions/brex.json +++ b/packages/shared/src/app-definitions/brex.json @@ -26,7 +26,29 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://api.brex.com/mcp" + "serverUrl": "https://api.brex.com/mcp", + "scopesHint": [ + "openid", + "offline_access", + "email", + "users.readonly", + "departments.readonly", + "locations.readonly", + "titles.readonly", + "legal_entities.readonly", + "cards.readonly", + "cards", + "companies.readonly", + "budgets.readonly", + "travel.trips.readonly", + "expenses.card.readonly", + "expenses.card", + "expenses.bill", + "accounts.cash.readonly", + "vendors.readonly", + "accounting.integration.read", + "accounting.record.read" + ] }, "guidanceMd": "Connect Brex in the browser. Brex early access and an administrator enabling the integration; financial actions require explicit approval.", "riskTier": "S4", diff --git a/packages/shared/src/app-definitions/browser-use-cloud.json b/packages/shared/src/app-definitions/browser-use-cloud.json index 534deb3cd3..28ae41d20c 100644 --- a/packages/shared/src/app-definitions/browser-use-cloud.json +++ b/packages/shared/src/app-definitions/browser-use-cloud.json @@ -36,7 +36,7 @@ "required": true, "placeholder": "bu_…", "secret": true, - "helperMd": "Open Browser Use → Settings → API keys. Create a key for the project agents should use." + "helperMd": "Create/stop browser sessions and run browser tasks under the API key. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/candid.json b/packages/shared/src/app-definitions/candid.json index 27d296682f..7d9668da94 100644 --- a/packages/shared/src/app-definitions/candid.json +++ b/packages/shared/src/app-definitions/candid.json @@ -25,7 +25,12 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://mcp.candid.org/mcp" + "serverUrl": "https://mcp.candid.org/mcp", + "scopesHint": [ + "openid", + "profile", + "email" + ] }, "guidanceMd": "Connect Candid in the browser. A Candid account with access to the MCP connector.", "riskTier": "S2", diff --git a/packages/shared/src/app-definitions/clickhouse.json b/packages/shared/src/app-definitions/clickhouse.json index b654c84173..f7ede2aa30 100644 --- a/packages/shared/src/app-definitions/clickhouse.json +++ b/packages/shared/src/app-definitions/clickhouse.json @@ -26,7 +26,14 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://mcp.clickhouse.cloud/clickstack" + "serverUrl": "https://mcp.clickhouse.cloud/clickstack", + "scopesHint": [ + "mcp:access", + "clickstack:access", + "openid", + "profile", + "email" + ] }, "guidanceMd": "Connect ClickHouse in the browser. A ClickHouse Cloud ClickStack service and its service ID.", "riskTier": "S4", diff --git a/packages/shared/src/app-definitions/cloudflare.json b/packages/shared/src/app-definitions/cloudflare.json index b90ec69dcc..693237c0a9 100644 --- a/packages/shared/src/app-definitions/cloudflare.json +++ b/packages/shared/src/app-definitions/cloudflare.json @@ -25,7 +25,11 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://mcp.cloudflare.com/mcp" + "serverUrl": "https://mcp.cloudflare.com/mcp", + "scopesHint": [ + "user:read", + "account:read" + ] }, "guidanceMd": "Connect Cloudflare in the browser. A Cloudflare account with access to the resources being connected.", "riskTier": "S3", @@ -58,7 +62,8 @@ "type": "password", "required": true, "placeholder": "Paste your Cloudflare API token", - "secret": true + "secret": true, + "helperMd": "Cloudflare API actions selected at provider consent or on the API token. Identity scopes alone do not grant account writes. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/cloudinary.json b/packages/shared/src/app-definitions/cloudinary.json index 027a8294c3..64231fb870 100644 --- a/packages/shared/src/app-definitions/cloudinary.json +++ b/packages/shared/src/app-definitions/cloudinary.json @@ -26,7 +26,16 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://asset-management.mcp.cloudinary.com/mcp" + "serverUrl": "https://asset-management.mcp.cloudinary.com/mcp", + "scopesHint": [ + "openid", + "profile", + "email", + "offline_access", + "asset_management", + "upload", + "media_generation" + ] }, "guidanceMd": "Connect Cloudinary in the browser. A Cloudinary account; authorization is limited by the signed-in user's roles.", "riskTier": "S3", diff --git a/packages/shared/src/app-definitions/coda.json b/packages/shared/src/app-definitions/coda.json index 515aa17401..f0f78684ae 100644 --- a/packages/shared/src/app-definitions/coda.json +++ b/packages/shared/src/app-definitions/coda.json @@ -25,7 +25,10 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://coda.io/apis/mcp" + "serverUrl": "https://coda.io/apis/mcp", + "scopesHint": [ + "mcp:all" + ] }, "guidanceMd": "Connect Coda in the browser. A Coda account; the hosted MCP service is currently beta.", "riskTier": "S3", @@ -59,7 +62,8 @@ "type": "password", "required": true, "placeholder": "Paste your Coda API token", - "secret": true + "secret": true, + "helperMd": "Modify documents/tables permitted by the account. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/cognee.json b/packages/shared/src/app-definitions/cognee.json index be5473950d..287beb3acf 100644 --- a/packages/shared/src/app-definitions/cognee.json +++ b/packages/shared/src/app-definitions/cognee.json @@ -55,7 +55,7 @@ "required": true, "placeholder": "Paste your Cognee API key", "secret": true, - "helperMd": "Create a key in Cognee → API Keys. The key is shown once." + "helperMd": "Add/process knowledge and manage authorized datasets. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/embat.json b/packages/shared/src/app-definitions/embat.json index ed9df4d1e6..f29752d20e 100644 --- a/packages/shared/src/app-definitions/embat.json +++ b/packages/shared/src/app-definitions/embat.json @@ -25,7 +25,13 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://tellme.embat.io/mcp" + "serverUrl": "https://tellme.embat.io/mcp", + "scopesHint": [ + "email", + "offline_access", + "openid", + "profile" + ] }, "guidanceMd": "Connect Embat in the browser. An Embat account; pilot the connection because provider setup documentation is sparse.", "riskTier": "S4", diff --git a/packages/shared/src/app-definitions/fireflies.json b/packages/shared/src/app-definitions/fireflies.json index 4677c3979e..2c4605a604 100644 --- a/packages/shared/src/app-definitions/fireflies.json +++ b/packages/shared/src/app-definitions/fireflies.json @@ -62,7 +62,8 @@ "type": "password", "required": true, "placeholder": "Paste your Fireflies API key", - "secret": true + "secret": true, + "helperMd": "Use an API key for an account with write access to the meetings your agents need to share, rename, move or turn into soundbites. Paperclip cannot increase the key’s permissions." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/github.json b/packages/shared/src/app-definitions/github.json index d55664bb35..8ea05d2eba 100644 --- a/packages/shared/src/app-definitions/github.json +++ b/packages/shared/src/app-definitions/github.json @@ -67,7 +67,8 @@ "type": "password", "required": true, "placeholder": "github_pat_...", - "secret": true + "secret": true, + "helperMd": "Repository contents, issues, pull requests and other granted toolsets; selected repos and installation/PAT permissions apply. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/honcho.json b/packages/shared/src/app-definitions/honcho.json index 6dd43ea2a1..ad5a94c78d 100644 --- a/packages/shared/src/app-definitions/honcho.json +++ b/packages/shared/src/app-definitions/honcho.json @@ -36,7 +36,8 @@ "type": "password", "required": true, "placeholder": "Paste your Honcho API key", - "secret": true + "secret": true, + "helperMd": "Create peers/sessions and save memory under the API key project. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/hugging-face.json b/packages/shared/src/app-definitions/hugging-face.json index a4da34753d..b1a5d9dce2 100644 --- a/packages/shared/src/app-definitions/hugging-face.json +++ b/packages/shared/src/app-definitions/hugging-face.json @@ -27,7 +27,10 @@ "defaults": { "serverUrl": "https://huggingface.co/mcp?login&gradio=none", "scopesHint": [ - "read-mcp" + "read-mcp", + "read-repos", + "contribute-repos", + "jobs" ] }, "guidanceMd": "Connect Hugging Face in the browser. A Hugging Face account.", diff --git a/packages/shared/src/app-definitions/kernel.json b/packages/shared/src/app-definitions/kernel.json index 4625e859ef..bdf3d17edd 100644 --- a/packages/shared/src/app-definitions/kernel.json +++ b/packages/shared/src/app-definitions/kernel.json @@ -25,7 +25,10 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://mcp.onkernel.com/mcp" + "serverUrl": "https://mcp.onkernel.com/mcp", + "scopesHint": [ + "openid" + ] }, "guidanceMd": "Connect Kernel in the browser. A Kernel account with either browser authorization or an API key.", "riskTier": "S3", @@ -58,7 +61,8 @@ "type": "password", "required": true, "placeholder": "Paste your Kernel API key", - "secret": true + "secret": true, + "helperMd": "Launch/manage browsers, profiles, apps and browser automation. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/local-falcon.json b/packages/shared/src/app-definitions/local-falcon.json index 775df913bb..f069afe88c 100644 --- a/packages/shared/src/app-definitions/local-falcon.json +++ b/packages/shared/src/app-definitions/local-falcon.json @@ -25,7 +25,11 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://mcp.localfalcon.com" + "serverUrl": "https://mcp.localfalcon.com", + "scopesHint": [ + "api", + "offline_access" + ] }, "guidanceMd": "Connect Local Falcon in the browser. A Local Falcon account with MCP access.", "riskTier": "S2", diff --git a/packages/shared/src/app-definitions/manufact.json b/packages/shared/src/app-definitions/manufact.json index 080ee7e348..8973ddd365 100644 --- a/packages/shared/src/app-definitions/manufact.json +++ b/packages/shared/src/app-definitions/manufact.json @@ -26,7 +26,13 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://mcp.manufact.com/mcp" + "serverUrl": "https://mcp.manufact.com/mcp", + "scopesHint": [ + "openid", + "profile", + "email", + "offline_access" + ] }, "guidanceMd": "Connect Manufact in the browser. A Manufact account with MCP access.", "riskTier": "S3", diff --git a/packages/shared/src/app-definitions/mem0.json b/packages/shared/src/app-definitions/mem0.json index c78893585d..95bb028d7e 100644 --- a/packages/shared/src/app-definitions/mem0.json +++ b/packages/shared/src/app-definitions/mem0.json @@ -36,7 +36,8 @@ "type": "password", "required": true, "placeholder": "Paste your Mem0 API key", - "secret": true + "secret": true, + "helperMd": "Add/update/delete memories under the API key account. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/miro.json b/packages/shared/src/app-definitions/miro.json index 1e973c2ecc..93191223db 100644 --- a/packages/shared/src/app-definitions/miro.json +++ b/packages/shared/src/app-definitions/miro.json @@ -25,7 +25,13 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://mcp.miro.com/" + "serverUrl": "https://mcp.miro.com/", + "scopesHint": [ + "boards:read", + "boards:write", + "openid", + "email" + ] }, "guidanceMd": "Connect Miro in the browser. A Miro account; enterprise administrators may restrict third-party MCP clients.", "riskTier": "S3", diff --git a/packages/shared/src/app-definitions/mixpanel.json b/packages/shared/src/app-definitions/mixpanel.json index cd84681be0..b6f4c355fb 100644 --- a/packages/shared/src/app-definitions/mixpanel.json +++ b/packages/shared/src/app-definitions/mixpanel.json @@ -26,7 +26,33 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://mcp.mixpanel.com/mcp" + "serverUrl": "https://mcp.mixpanel.com/mcp", + "scopesHint": [ + "projects", + "analysis", + "events", + "insights", + "segmentation", + "retention", + "data:read", + "funnels", + "flows", + "data_definitions", + "alerts", + "annotations", + "bookmarks", + "business_context", + "cohorts", + "context", + "custom_alerts", + "custom_events", + "custom_properties", + "dashboard_reports", + "experiments", + "feature_flags", + "metrics", + "user_details" + ] }, "guidanceMd": "Connect Mixpanel in the browser. A Mixpanel account; the hosted MCP server is currently beta.", "riskTier": "S3", diff --git a/packages/shared/src/app-definitions/netlify.json b/packages/shared/src/app-definitions/netlify.json index c034274a8c..b94365447e 100644 --- a/packages/shared/src/app-definitions/netlify.json +++ b/packages/shared/src/app-definitions/netlify.json @@ -26,7 +26,12 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://netlify-mcp.netlify.app/mcp" + "serverUrl": "https://netlify-mcp.netlify.app/mcp", + "scopesHint": [ + "offline_access", + "read", + "write" + ] }, "guidanceMd": "Connect Netlify in the browser. A Netlify account with access to the relevant team and sites.", "riskTier": "S3", diff --git a/packages/shared/src/app-definitions/notion.json b/packages/shared/src/app-definitions/notion.json index c37399eca9..54d07923fc 100644 --- a/packages/shared/src/app-definitions/notion.json +++ b/packages/shared/src/app-definitions/notion.json @@ -24,7 +24,10 @@ ], "whenToUse": "Use the provider-hosted connection for the quickest setup.", "defaults": { - "serverUrl": "https://mcp.notion.com/mcp" + "serverUrl": "https://mcp.notion.com/mcp", + "scopesHint": [ + "default" + ] }, "guidanceMd": "Connect Notion for workspace content. Share only the pages and databases agents should use.", "riskTier": "S3", diff --git a/packages/shared/src/app-definitions/oreilly.json b/packages/shared/src/app-definitions/oreilly.json index 7719caceda..297f25e790 100644 --- a/packages/shared/src/app-definitions/oreilly.json +++ b/packages/shared/src/app-definitions/oreilly.json @@ -25,7 +25,11 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://api.oreilly.com/api/content-discovery/v1/mcp/" + "serverUrl": "https://api.oreilly.com/api/content-discovery/v1/mcp/", + "scopesHint": [ + "openid", + "offline_access" + ] }, "guidanceMd": "Connect O'Reilly in the browser. An O'Reilly Learning subscription with MCP or API access.", "riskTier": "S2", @@ -58,7 +62,8 @@ "type": "password", "required": true, "placeholder": "Paste your O'Reilly API token", - "secret": true + "secret": true, + "helperMd": "None: content discovery and retrieval. A read-only key is sufficient." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/pagerduty.json b/packages/shared/src/app-definitions/pagerduty.json index c699b7eb57..47dd41d1cf 100644 --- a/packages/shared/src/app-definitions/pagerduty.json +++ b/packages/shared/src/app-definitions/pagerduty.json @@ -36,7 +36,8 @@ "type": "password", "required": true, "placeholder": "Paste your PagerDuty user API token", - "secret": true + "secret": true, + "helperMd": "Incident and on-call changes require a full-access API key and role access. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { @@ -73,7 +74,8 @@ "type": "password", "required": true, "placeholder": "Paste your PagerDuty user API token", - "secret": true + "secret": true, + "helperMd": "Incident and on-call changes require a full-access API key and role access. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/planetscale.json b/packages/shared/src/app-definitions/planetscale.json index da45725635..96d64734ee 100644 --- a/packages/shared/src/app-definitions/planetscale.json +++ b/packages/shared/src/app-definitions/planetscale.json @@ -59,7 +59,12 @@ "organization", "database", "branch" - ] + ], + "capabilityProfile": { + "key": "write", + "label": "Read and write", + "description": "Query and change the databases you authorize in PlanetScale." + } }, { "key": "mcp-insights-only", @@ -85,7 +90,12 @@ "organization", "database", "branch" - ] + ], + "capabilityProfile": { + "key": "read", + "label": "Read only", + "description": "Inspect database performance with the insights-only server." + } } ] } diff --git a/packages/shared/src/app-definitions/posthog.json b/packages/shared/src/app-definitions/posthog.json index c74b5d2e40..6042a291a2 100644 --- a/packages/shared/src/app-definitions/posthog.json +++ b/packages/shared/src/app-definitions/posthog.json @@ -241,7 +241,8 @@ "type": "password", "required": true, "placeholder": "phx_...", - "secret": true + "secret": true, + "helperMd": "Analytics objects, feature flags, experiments, error triage and other enabled product actions. readonly/feature/tool filters remain enforced. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/postman.json b/packages/shared/src/app-definitions/postman.json index 073c042ffb..bcee846eab 100644 --- a/packages/shared/src/app-definitions/postman.json +++ b/packages/shared/src/app-definitions/postman.json @@ -115,7 +115,8 @@ "type": "password", "required": true, "placeholder": "PMAK-...", - "secret": true + "secret": true, + "helperMd": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { @@ -157,7 +158,8 @@ "type": "password", "required": true, "placeholder": "PMAK-...", - "secret": true + "secret": true, + "helperMd": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { @@ -199,7 +201,8 @@ "type": "password", "required": true, "placeholder": "PMAK-...", - "secret": true + "secret": true, + "helperMd": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/razorpay.json b/packages/shared/src/app-definitions/razorpay.json index c84f1e15bc..e2abe5d6f5 100644 --- a/packages/shared/src/app-definitions/razorpay.json +++ b/packages/shared/src/app-definitions/razorpay.json @@ -60,7 +60,8 @@ "type": "password", "required": true, "placeholder": "Paste the base64-encoded key ID and secret", - "secret": true + "secret": true, + "helperMd": "Provider-authorized payment/account tools; financial policy gates remain. Live verification outstanding. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/resend.json b/packages/shared/src/app-definitions/resend.json index f9953b279a..4bb386d5b2 100644 --- a/packages/shared/src/app-definitions/resend.json +++ b/packages/shared/src/app-definitions/resend.json @@ -26,7 +26,10 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://mcp.resend.com/mcp" + "serverUrl": "https://mcp.resend.com/mcp", + "scopesHint": [ + "full_access" + ] }, "guidanceMd": "Connect Resend in the browser. A Resend account with access to the relevant domains.", "riskTier": "S3", diff --git a/packages/shared/src/app-definitions/sanity.json b/packages/shared/src/app-definitions/sanity.json index bc4079ed1c..15968d52eb 100644 --- a/packages/shared/src/app-definitions/sanity.json +++ b/packages/shared/src/app-definitions/sanity.json @@ -26,7 +26,10 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://mcp.sanity.io" + "serverUrl": "https://mcp.sanity.io", + "scopesHint": [ + "global" + ] }, "guidanceMd": "Connect Sanity in the browser. A Sanity account with access to the relevant projects and datasets.", "riskTier": "S3", @@ -59,7 +62,8 @@ "type": "password", "required": true, "placeholder": "sk...", - "secret": true + "secret": true, + "helperMd": "Edit content permitted by project role; global is MCP authorization, not a bypass of project ACLs. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/sentry.json b/packages/shared/src/app-definitions/sentry.json index 98fc155680..b626e6e870 100644 --- a/packages/shared/src/app-definitions/sentry.json +++ b/packages/shared/src/app-definitions/sentry.json @@ -26,7 +26,14 @@ "whenToUse": "Use the provider-hosted connection for the quickest setup.", "defaults": { "serverUrl": "https://mcp.sentry.dev/mcp", - "discoveryUrl": "https://sentry.io/.well-known/oauth-authorization-server" + "discoveryUrl": "https://sentry.io/.well-known/oauth-authorization-server", + "scopesHint": [ + "org:read", + "project:write", + "team:write", + "event:write", + "alerts:write" + ] }, "guidanceMd": "Connect the Sentry organization and projects agents need for incident work.", "riskTier": "S2", diff --git a/packages/shared/src/app-definitions/similarweb.json b/packages/shared/src/app-definitions/similarweb.json index fc31306050..37a912287b 100644 --- a/packages/shared/src/app-definitions/similarweb.json +++ b/packages/shared/src/app-definitions/similarweb.json @@ -37,7 +37,8 @@ "type": "password", "required": true, "placeholder": "Paste your Similarweb API key", - "secret": true + "secret": true, + "helperMd": "None: analytics retrieval; subscription entitlements apply. A read-only key is sufficient." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/slack.json b/packages/shared/src/app-definitions/slack.json index 5e3b2a100e..208c7aaccb 100644 --- a/packages/shared/src/app-definitions/slack.json +++ b/packages/shared/src/app-definitions/slack.json @@ -28,9 +28,36 @@ "authorizationEndpoint": "https://slack.com/oauth/v2/authorize", "tokenEndpoint": "https://slack.com/api/oauth.v2.access", "scopesHint": [ + "canvases:read", + "canvases:write", + "channels:history", "channels:read", + "channels:write", "chat:write", - "search:read" + "emoji:read", + "files:read", + "files:write", + "groups:history", + "groups:read", + "groups:write", + "im:history", + "im:read", + "im:write", + "lists:read", + "lists:write", + "mpim:history", + "mpim:read", + "mpim:write", + "reactions:read", + "reactions:write", + "search:read.files", + "search:read.im", + "search:read.mpim", + "search:read.private", + "search:read.public", + "search:read.users", + "users:read", + "users:read.email" ] }, "guidanceMd": "Connect a Slack workspace and limit access to the channels agents need.", diff --git a/packages/shared/src/app-definitions/stripe.json b/packages/shared/src/app-definitions/stripe.json index ad2124f590..708fb6d349 100644 --- a/packages/shared/src/app-definitions/stripe.json +++ b/packages/shared/src/app-definitions/stripe.json @@ -25,7 +25,10 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://mcp.stripe.com" + "serverUrl": "https://mcp.stripe.com", + "scopesHint": [ + "mcp" + ] }, "guidanceMd": "Connect Stripe in the browser. A Stripe account; the server is public preview and payment actions require explicit approval.", "riskTier": "S4", @@ -59,7 +62,8 @@ "type": "password", "required": true, "placeholder": "sk_...", - "secret": true + "secret": true, + "helperMd": "Account- and sandbox-specific writes selected during Stripe consent; financial approvals and restricted-key permissions remain. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { diff --git a/packages/shared/src/app-definitions/supabase.json b/packages/shared/src/app-definitions/supabase.json index afdf03cda0..5580581b0b 100644 --- a/packages/shared/src/app-definitions/supabase.json +++ b/packages/shared/src/app-definitions/supabase.json @@ -25,7 +25,22 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://mcp.supabase.com/mcp" + "serverUrl": "https://mcp.supabase.com/mcp", + "scopesHint": [ + "organizations:read", + "projects:read", + "projects:write", + "database:write", + "database:read", + "analytics:read", + "secrets:read", + "edge_functions:read", + "edge_functions:write", + "environment:read", + "environment:write", + "storage:read", + "storage:write" + ] }, "guidanceMd": "Connect Supabase in the browser and scope the connection to one development project. Write tools start enabled and remain governed by Paperclip's action policies.", "riskTier": "S4", @@ -60,7 +75,8 @@ "location": "query", "name": "read_only", "format": "boolean" - } + }, + "advanced": true }, { "key": "features", @@ -101,7 +117,8 @@ "type": "password", "required": true, "placeholder": "sbp_...", - "secret": true + "secret": true, + "helperMd": "Project/database/environment/storage changes and Edge Function deployment. Project selection and optional read-only configuration remain authoritative. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions." } ], "keyPlacement": { @@ -140,7 +157,8 @@ "location": "query", "name": "read_only", "format": "boolean" - } + }, + "advanced": true }, { "key": "features", diff --git a/packages/shared/src/app-definitions/ticktick.json b/packages/shared/src/app-definitions/ticktick.json index ef676cb980..9366a275b0 100644 --- a/packages/shared/src/app-definitions/ticktick.json +++ b/packages/shared/src/app-definitions/ticktick.json @@ -25,7 +25,11 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://mcp.ticktick.com" + "serverUrl": "https://mcp.ticktick.com", + "scopesHint": [ + "tasks:write", + "tasks:read" + ] }, "guidanceMd": "Connect TickTick in the browser. A TickTick account with MCP access.", "riskTier": "S3", diff --git a/packages/shared/src/app-definitions/todoist.json b/packages/shared/src/app-definitions/todoist.json index 79d21387d9..25ee5e88dc 100644 --- a/packages/shared/src/app-definitions/todoist.json +++ b/packages/shared/src/app-definitions/todoist.json @@ -25,7 +25,10 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://ai.todoist.net/mcp" + "serverUrl": "https://ai.todoist.net/mcp", + "scopesHint": [ + "data:read_write" + ] }, "guidanceMd": "Connect Todoist in the browser. A Todoist account.", "riskTier": "S3", diff --git a/packages/shared/src/app-definitions/vercel.json b/packages/shared/src/app-definitions/vercel.json index 57baef5f21..7a871d4836 100644 --- a/packages/shared/src/app-definitions/vercel.json +++ b/packages/shared/src/app-definitions/vercel.json @@ -25,7 +25,10 @@ ], "whenToUse": "Use the provider-hosted connection for the quickest setup.", "defaults": { - "serverUrl": "https://mcp.vercel.com/mcp" + "serverUrl": "https://mcp.vercel.com/mcp", + "scopesHint": [ + "openid" + ] }, "guidanceMd": "Connect the Vercel team and projects agents should operate.", "riskTier": "S3", diff --git a/packages/shared/src/app-definitions/wix.json b/packages/shared/src/app-definitions/wix.json index 0565ecbd58..978c310f36 100644 --- a/packages/shared/src/app-definitions/wix.json +++ b/packages/shared/src/app-definitions/wix.json @@ -26,7 +26,10 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://mcp.wix.com/mcp" + "serverUrl": "https://mcp.wix.com/mcp", + "scopesHint": [ + "offline_access" + ] }, "guidanceMd": "Connect Wix in the browser. A Wix account with access to the relevant sites.", "riskTier": "S3", diff --git a/packages/shared/src/app-definitions/youcom.json b/packages/shared/src/app-definitions/youcom.json index dc9479cc19..2993fbbda9 100644 --- a/packages/shared/src/app-definitions/youcom.json +++ b/packages/shared/src/app-definitions/youcom.json @@ -26,7 +26,11 @@ ], "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", "defaults": { - "serverUrl": "https://api.you.com/mcp" + "serverUrl": "https://api.you.com/mcp", + "scopesHint": [ + "Tools", + "offline_access" + ] }, "guidanceMd": "Connect You.com in the browser. A You.com account for browser sign-in, or a You.com API key from you.com/platform for higher rate limits; a keyless free profile is also available.", "riskTier": "S2", @@ -59,7 +63,8 @@ "type": "password", "required": true, "placeholder": "Paste your You.com API key", - "secret": true + "secret": true, + "helperMd": "None: search, contents and research tools (usage may be billed). A read-only key is sufficient." } ], "keyPlacement": { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 97bf5c3e0d..2197f11128 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -321,6 +321,7 @@ export { connectionMethodSupportsCatalogSetup, connectionMethodSupportsAutomaticOAuth, credentialConfigPath, + connectionCredentialConfigPath, getAppDefinitionForUrl, getAppStoreDefinition, getAvailableConnectionMethod, diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index 66ffd7c9dc..4b3c8dc789 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -1627,6 +1627,39 @@ for (const [slug, name, subscription, envKey] of [["anthropic", "Claude", true, // AI account flow; saved REST connections remain removable through Connections. app.methods = [...methods, ...app.methods.filter(method => method.transport !== "rest_api")]; } +// Every tool method has a checked-in permission review. Discovery metadata is +// evidence for reviewers, never a runtime instruction to request more scopes. +const permissionReviews = JSON.parse(fs.readFileSync( + path.join(root, "doc/connections/tool-method-permission-reviews.json"), "utf8", +)).methods; +for (const app of apps) { + for (const connectionMethod of app.methods) { + if (["channel", "ai"].includes(connectionMethod.purpose)) continue; + const review = permissionReviews.find((entry) => entry.app === app.slug && entry.method === connectionMethod.key); + if (!review) throw new Error(`${app.slug}/${connectionMethod.key}: permission review required`); + if (connectionMethod.auth === "oauth") { + if (review.policy === "explicit") { + connectionMethod.defaults = { ...connectionMethod.defaults, scopesHint: review.requestedScopes }; + } else if (review.policy !== "provider-default" || !review.providerDefaultReason) { + throw new Error(`${app.slug}/${connectionMethod.key}: reviewed scopes or documented provider default required`); + } + } + for (const configField of connectionMethod.tenantFields ?? []) { + if (configField.key === "readOnly") configField.advanced = true; + } + if (review.keyPermissions) { + for (const credential of connectionMethod.credentialFields ?? []) { + if (credential.secret !== false) credential.helperMd = review.keyPermissions; + } + } + if (app.slug === "planetscale") { + connectionMethod.capabilityProfile = connectionMethod.key === "mcp-insights-only" + ? { key: "read", label: "Read only", description: "Inspect database performance with the insights-only server." } + : { key: "write", label: "Read and write", description: "Query and change the databases you authorize in PlanetScale." }; + } + } +} + const validateApp = (app) => { if ( app.schemaVersion !== 1 || diff --git a/server/src/__tests__/project-repositories-persistence.test.ts b/server/src/__tests__/project-repositories-persistence.test.ts index 1988804295..d2e6b3dc4f 100644 --- a/server/src/__tests__/project-repositories-persistence.test.ts +++ b/server/src/__tests__/project-repositories-persistence.test.ts @@ -1,12 +1,14 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { companies, companyMemberships, companySecrets, connectionGrants, connectionGrantMembers, toolApplications, toolConnections, createDb, projects as projectTable } from "@paperclipai/db"; +import { companies, companyMemberships, companySecrets, connectionGrants, connectionGrantMembers, toolApplications, toolConnections, createDb, projects as projectTable, userSecretDefinitions } from "@paperclipai/db"; import { eq } from "drizzle-orm"; import { toolAccessService } from "../services/tool-access.js"; import { projectService } from "../services/projects.js"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +const personalTokens = vi.hoisted(() => new Map()); vi.mock("../services/secrets.js", () => ({ secretService: () => ({ resolveSecretValue: async (_companyId: string, secretId: string) => secretId, + resolveUserSecretValue: async (_companyId: string, input: { definitionId: string }) => ({ value: personalTokens.get(input.definitionId) }), }) })); const support = await getEmbeddedPostgresTestSupport(); @@ -21,7 +23,7 @@ const support = await getEmbeddedPostgresTestSupport(); [companyId] = (await db.insert(companies).values({ name: "Repositories", issuePrefix: "REPO" }).returning()).map((company) => company.id); }, 20_000); afterAll(async () => { await temp?.cleanup(); }); - afterEach(() => vi.unstubAllGlobals()); + afterEach(() => { vi.unstubAllGlobals(); personalTokens.clear(); }); it("creates multiple source repos atomically and persists them across reads", async () => { const svc = projectService(db); @@ -69,9 +71,15 @@ const support = await getEmbeddedPostgresTestSupport(); await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "alice", membershipRole: "admin" }); const [otherCompany] = await db.insert(companies).values({ name: "Other", issuePrefix: "OTHER" }).returning(); const tokenRepos = new Map>(); - async function connection(name: string, kind: "user" | "organization", owner: string | null, audience: string[] = [], targetCompanyId = companyId) { + async function connection(name: string, kind: "user" | "organization", owner: string | null, audience: string[] = [], targetCompanyId = companyId, legacyCompanyCredential = false) { const [app] = await db.insert(toolApplications).values({ companyId: targetCompanyId, name, type: "mcp_http" }).returning(); - const [secret] = await db.insert(companySecrets).values({ companyId: targetCompanyId, name, key: name }).returning(); + const definition = kind === "user" && !legacyCompanyCredential + ? (await db.insert(userSecretDefinitions).values({ companyId: targetCompanyId, name, key: name }).returning())[0] + : undefined; + const [secret] = await db.insert(companySecrets).values({ companyId: targetCompanyId, name, key: name, + scope: definition ? "user" : "company", ownerUserId: definition ? owner : null, + userSecretDefinitionId: definition?.id }).returning(); + if (definition) personalTokens.set(definition.id, secret.id); const [conn] = await db.insert(toolConnections).values({ companyId: targetCompanyId, applicationId: app.id, name, uid: name, status: "active", enabled: true, transport: "mcp_remote", authKind: "api_key", credentialPolicy: kind === "user" ? "per_user" : "shared", config: { sourceTemplateKey: "github" } }).returning(); const [grant] = await db.insert(connectionGrants).values({ companyId: targetCompanyId, connectionId: conn.id, kind, subjectUserId: owner, @@ -85,18 +93,19 @@ const support = await getEmbeddedPostgresTestSupport(); const restricted = await connection("restricted", "organization", null, ["bob"]); const crossCompany = await connection("cross-company", "organization", null, [], otherCompany.id); const broken = await connection("broken", "organization", null); + const legacyPersonal = await connection("legacy-personal", "user", "alice", [], companyId, true); tokenRepos.set(personal, [{ id: 10, full_name: "org/common" }, { id: 11, full_name: "alice/private" }]); tokenRepos.set(shared, [{ id: 10, full_name: "org/common" }, { id: 12, full_name: "org/shared" }]); const request = vi.fn(async (_url, init) => { const token = new Headers(init?.headers).get("authorization")?.replace("Bearer ", "") ?? ""; if (token === broken) return new Response("secret-provider-error", { status: 503 }); - expect([otherPerson, restricted, crossCompany]).not.toContain(token); + expect([otherPerson, restricted, crossCompany, legacyPersonal]).not.toContain(token); return Response.json(tokenRepos.get(token) ?? []); }); vi.stubGlobal("fetch", request); const result = await toolAccessService(db).listProjectRepositories(companyId, "alice"); - expect(result.connectionCount).toBe(3); - expect(result.failedConnectionCount).toBe(1); + expect(result.connectionCount).toBe(4); + expect(result.failedConnectionCount).toBe(2); expect(result.repositories.map((repo) => repo.id).sort()).toEqual(["10", "11", "12"]); expect(result.repositories.find((repo) => repo.id === "10")?.connections.sort()).toEqual(["personal", "shared"]); expect(JSON.stringify(result)).not.toContain("secret-provider-error"); @@ -107,7 +116,7 @@ const support = await getEmbeddedPostgresTestSupport(); expect(await toolAccessService(db).githubReadConnectionIds(otherCompany.id, "alice")).toEqual([]); const ownConnection = savedConnections.find(connection => connection.name === "personal")!; expect(await toolAccessService(db).githubReadHeaders(companyId, ownConnection.id, "alice")).toEqual({ Authorization: `Bearer ${personal}` }); - for (const name of ["other-person", "restricted"]) { + for (const name of ["other-person", "restricted", "legacy-personal"]) { await expect(toolAccessService(db).githubReadHeaders(companyId, savedConnections.find(connection => connection.name === name)!.id, "alice")).rejects.toThrow(/authorization/); } await expect(toolAccessService(db).githubReadHeaders(otherCompany.id, ownConnection.id, "alice", true)).rejects.toThrow(/unavailable/); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 8d06d552c4..f2b45f8e3d 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -1,3 +1,4 @@ +import * as fs from "node:fs/promises"; import { createHash, generateKeyPairSync, randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; @@ -2440,6 +2441,34 @@ describeEmbeddedPostgres("tool access service", () => { } }); + it.each(["airtable", "beehiiv", "miro", "netlify", "sentry", "supabase", "todoist", "ticktick", "hugging-face"])( + "requests the reviewed read/write scopes for %s without adopting advertised admin scopes", + async (slug) => { + const company = await createCompany(db); + const actor = { actorType: "user" as const, actorId: "alice" }; + const service = createTestToolAccessService(db, { + remoteHttpRequest: async (url, init) => { + const origin = new URL(url).origin; + if (init.method === "POST") return Response.json({ client_id: "fixture-client", ...JSON.parse(String(init.body)) }); + return Response.json({ issuer: origin, authorization_endpoint: `${origin}/authorize`, + token_endpoint: `${origin}/token`, registration_endpoint: `${origin}/register`, + scopes_supported: ["unrelated:admin"], response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], + }); + }, + }); + const connected = await service.connectGalleryApp(company.id, { galleryKey: slug, connectionMethodKey: "mcp-oauth", grantKind: "user", ...(slug === "supabase" ? { configValues: { projectRef: "abcdefghijklmnopqrst" } } : {}) }, actor); + const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor }); + const review = JSON.parse(await fs.readFile(new URL("../../../doc/connections/tool-method-permission-reviews.json", import.meta.url), "utf8")) + .methods.find((entry: { app: string; method: string }) => entry.app === slug && entry.method === "mcp-oauth"); + expect(new URL(started.authorizationUrl).searchParams.get("scope")?.split(" ")).toEqual(review.requestedScopes); + expect(new URL(started.authorizationUrl).searchParams.get("scope")).not.toContain("unrelated:admin"); + await expect(service.startOAuth(company.id, connected.connectionId, { + redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, scopes: ["unrelated:admin"], + })).rejects.toMatchObject({ details: { code: "oauth_scope_widening_rejected" } }); + }, + ); + it("keeps tools outside a Google Workspace capability profile disabled", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); @@ -8011,7 +8040,6 @@ describeEmbeddedPostgres("tool access service", () => { ), ); expect(bindings.map((binding) => binding.configPath).sort()).toEqual([ - "credentials.oauth.access_token", "oauth.access_token", "oauth.refresh_token", ]); @@ -10365,7 +10393,7 @@ describeEmbeddedPostgres("tool access service", () => { await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(0); await expect( db.select().from(companySecretBindings), - ).resolves.toHaveLength(6); + ).resolves.toHaveLength(4); const [connection] = await db .select() .from(toolConnections) @@ -10879,7 +10907,7 @@ describeEmbeddedPostgres("tool access service", () => { ); expect( new URL(first.authorizationUrl).searchParams.get("scope"), - ).toBeNull(); + ).toBe("default"); expect( new URL(concurrent.authorizationUrl).searchParams.get("client_id"), ).toBe("notion-dcr-client"); @@ -10972,7 +11000,7 @@ describeEmbeddedPostgres("tool access service", () => { clientTokenEndpointAuthMethod: "none", clientRedirectUri: redirectUri, registrationUrl: "https://mcp.notion.com/register", - scopes: [], + scopes: ["default"], }, }); expect(connection.credentialSecretRefs).toEqual([ @@ -12042,7 +12070,7 @@ describeEmbeddedPostgres("tool access service", () => { outcome: "success", }), expect.objectContaining({ - configPath: "credentials.oauth.access_token", + configPath: "oauth.access_token", outcome: "success", }), ]), @@ -15545,7 +15573,7 @@ describeEmbeddedPostgres("tool access service", () => { { actorType: "user", actorId: "board" }, ), ).rejects.toMatchObject({ - message: expect.stringContaining("Paste a new key"), + message: expect.stringContaining("Enter a replacement credential"), }); const result = await service.reconnectGalleryApp( diff --git a/server/src/__tests__/tool-connection-removal.test.ts b/server/src/__tests__/tool-connection-removal.test.ts index e7bb949c2e..925dfb04cd 100644 --- a/server/src/__tests__/tool-connection-removal.test.ts +++ b/server/src/__tests__/tool-connection-removal.test.ts @@ -16,6 +16,9 @@ import { createDb, heartbeatRuns, issues, + managedAgentProfiles, + routines, + routineTriggers, secretAccessEvents, toolAccessAuditEvents, toolApplications, @@ -180,6 +183,8 @@ describeEmbeddedPostgres("tool connection removal", () => { await db.delete(secretAccessEvents); await db.delete(companySecretBindings); await db.delete(companySecretVersions); + await db.delete(managedAgentProfiles); + await db.delete(routines); await db.delete(companySecrets); await db.delete(activityLog); await db.delete(toolAccessAuditEvents); @@ -245,7 +250,7 @@ describeEmbeddedPostgres("tool connection removal", () => { await expect(secretService(db).resolveSecretValue(company.id, secretIds[0]!, "latest", { consumerType: "tool_connection", consumerId: connectionId, - configPath: `credentials.headers.${HEADER.name}`, + configPath: `headers.${HEADER.name}`, actorType: "system", })).resolves.toBe(HEADER.value); await expect(policy.decide({ @@ -278,7 +283,7 @@ describeEmbeddedPostgres("tool connection removal", () => { await expect(secretService(db).resolveSecretValue(company.id, secretId, "latest", { consumerType: "tool_connection", consumerId: connectionId, - configPath: `credentials.headers.${HEADER.name}`, + configPath: `headers.${HEADER.name}`, actorType: "system", })).rejects.toMatchObject({ status: 404 }); } @@ -399,6 +404,26 @@ describeEmbeddedPostgres("tool connection removal", () => { expect(issuance!.path).toBe("oauth_access"); }); + it.each(["managed-profile", "routine-trigger"])("retains a credential referenced directly by a %s", async (consumer) => { + installMcpFixture(HEADER); + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const { service, connectionId } = await connectHeaderApp(company.id, agent.id); + const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId)); + const secretId = connection!.credentialRefs[0]!.secretId; + if (consumer === "managed-profile") { + await db.insert(managedAgentProfiles).values({ companyId: company.id, profileKey: "shared", displayName: "Shared", + anthropicAgentId: "fixture", agentVersion: "1", environmentId: "fixture", apiKeySecretId: secretId }); + } else { + const [routine] = await db.insert(routines).values({ companyId: company.id, title: "Shared" }).returning(); + await db.insert(routineTriggers).values({ companyId: company.id, routineId: routine!.id, kind: "webhook", secretId }); + } + const removed = await service.archiveConnection(connectionId, company.id); + expect(removed.removal.secretsRetainedShared).toBe(1); + expect((await db.select().from(companySecrets).where(eq(companySecrets.id, secretId)))[0]!.status).toBe("active"); + await expect(secretService(db).resolveSecretValue(company.id, secretId, "latest")).resolves.toBe(HEADER.value); + }); + it("leaves another consumer's credential in place", async () => { installMcpFixture(HEADER); const company = await createCompany(db); @@ -513,7 +538,7 @@ describeEmbeddedPostgres("tool connection removal", () => { await expect(secretService(db).resolveSecretValue(company.id, secretIds[0]!, "latest", { consumerType: "tool_connection", consumerId: connectionId, - configPath: `credentials.headers.${HEADER.name}`, + configPath: `headers.${HEADER.name}`, actorType: "system", })).rejects.toMatchObject({ status: 404 }); // The ref survives the failure on purpose: it is the only pointer a retry diff --git a/server/src/__tests__/tool-gateway.test.ts b/server/src/__tests__/tool-gateway.test.ts index c816682fcc..51ee450db0 100644 --- a/server/src/__tests__/tool-gateway.test.ts +++ b/server/src/__tests__/tool-gateway.test.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; import { createServer, type IncomingMessage } from "node:http"; import express from "express"; -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import request from "supertest"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { @@ -55,6 +55,7 @@ import { summarizeToolValue, } from "../services/tool-content-guards.js"; import { createToolGatewayService, ToolGatewayHttpError } from "../services/tool-gateway.js"; +import { resolveConnectionGrantSecret } from "../services/connection-credentials.js"; import { secretService } from "../services/secrets.js"; import * as cogneeBridge from "../services/cognee-connection.js"; import { createKvDemoHttpServer, type KvDemoHttpServer } from "../../../packages/kv-demo-mcp-server/src/http.js"; @@ -546,7 +547,7 @@ describeEmbeddedPostgres("tool gateway acceptance", () => { beforeAll(async () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-tool-gateway-"); db = createDb(tempDb.connectionString); - }, 20_000); + }, 90_000); afterEach(async () => { await db.delete(activityLog); @@ -2466,6 +2467,177 @@ rl.on("line", (line) => { } }); + it.each(([ + "zapier", "url", "bearer", "header", "public", + ] as const).flatMap((kind) => (kind === "public" ? ["setup"] : ["setup", "inline"]) + .map((reconnectMode) => ({ kind, reconnectMode }))))( + "executes $kind setup and $reconnectMode reconnect through a run-scoped gateway with canonical vault declarations", + async ({ kind, reconnectMode }) => { + for (const grantKind of ["user", "organization"] as const) { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const { run } = await createIssueAndRun(db, company.id, agent.id); + await createActiveMember(db, company.id, "alice"); + await db.update(heartbeatRuns).set({ responsibleUserId: "alice" }).where(eq(heartbeatRuns.id, run.id)); + const secretUrl = kind === "zapier" + ? "https://mcp.zapier.com/api/v1/connect?token=fixture-canary" + : "https://8.8.8.8/mcp?token=fixture-canary"; + const calls: { url: string; headers: Headers; method: unknown }[] = []; + const remoteHttpRequest: NonNullable = async (url, init) => { + const body = JSON.parse(String(init.body ?? "{}")); + calls.push({ url: String(url), headers: new Headers(init.headers), method: body.method }); + if (body.method === "notifications/initialized") return new Response(null, { status: 202 }); + return Response.json({ jsonrpc: "2.0", id: body.id, result: + body.method === "initialize" ? { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } } + : body.method === "tools/list" ? { tools: [ + { name: "read_fixture", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } }, + { name: "write_fixture", inputSchema: { type: "object" }, annotations: { readOnlyHint: false, destructiveHint: false } }, + ] } : { content: [{ type: "text", text: "performed" }] }, + }); + }; + const options = { remoteHttpRequest, remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] }; + const service = toolAccessService(db, options); + const setupInput: Parameters[1] = { + name: `Fixture ${kind}`, grantKind, + ...(kind === "zapier" ? { galleryKey: "zapier", connectionMethodKey: "generated-url", link: secretUrl } + : kind === "url" ? { link: secretUrl } + : kind === "bearer" ? { link: "https://8.8.8.8/mcp", authMode: "bearer" as const, credentialValues: { "credentials.authorization": "fixture-canary" } } + : kind === "header" ? { link: "https://8.8.8.8/mcp", authMode: "custom_headers" as const, credentialValues: { "headers.X-Api-Key": "fixture-canary" } } + : { link: "https://8.8.8.8/mcp", authMode: "none" as const }), + }; + const connected = await service.connectGalleryApp(company.id, setupInput, { actorType: "user", actorId: "alice" }); + const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId)); + const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, connected.connectionId)); + const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id)); + let activeSecretId = secret?.id; + if (kind !== "public") { + expect(secret).toMatchObject({ scope: grantKind === "user" ? "user" : "company", ownerUserId: grantKind === "user" ? "alice" : null }); + const expectedPath = kind === "header" ? "headers.X-Api-Key" : kind === "bearer" ? "credentials.authorization" : "remote.url"; + expect(grant!.credentialSecretRefs[0]!.configPath).toBe(expectedPath); + const declarations = await db.select().from(userSecretDeclarations).where(eq(userSecretDeclarations.targetId, connection!.id)); + const bindings = await db.select().from(companySecretBindings).where(eq(companySecretBindings.targetId, connection!.id)); + expect(grantKind === "user" ? declarations : bindings).toEqual([expect.objectContaining({ configPath: expectedPath })]); + expect(grantKind === "user" ? bindings : declarations).toEqual([]); + } else expect(secret).toBeUndefined(); + await service.finishGalleryAppConnection(company.id, connected.connectionId, { + enabledCatalogEntryIds: connected.catalog.map((entry) => entry.id), + askFirstCatalogEntryIds: [], access: { agentIds: [agent.id] }, + }, { actorType: "user", actorId: "alice" }); + await allowAllToolsForAgent(db, company.id, agent.id); + const gateway = createTestToolGatewayService(db, options); + const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const tools = (await gateway.listToolsForSession(session.token)).filter((tool) => tool.providerType === "mcp_remote_http"); + expect(tools).toHaveLength(2); + for (const tool of tools) { + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} })) + .resolves.toMatchObject({ status: "completed", result: { content: "performed" } }); + } + for (const call of calls.filter((call) => call.method === "tools/call")) { + if (kind === "zapier" || kind === "url") expect(call.url).toBe(secretUrl); + if (kind === "bearer") expect(call.headers.get("authorization")).toBe("Bearer fixture-canary"); + if (kind === "header") expect(call.headers.get("x-api-key")).toBe("fixture-canary"); + } + expect(JSON.stringify(await db.select().from(toolAccessAuditEvents))).not.toContain("fixture-canary"); + if (grantKind === "user" && secret) { + await expect(resolveConnectionGrantSecret(db, connection!, { ...grant!, subjectUserId: "bob" }, grant!.credentialSecretRefs[0]!, {})) + .rejects.toMatchObject({ details: { code: "grant_credential_invalid" } }); + // Health must reject the exact legacy ownership mismatch that invocation rejects. + await db.update(companySecrets).set({ scope: "company", ownerUserId: null, userSecretDefinitionId: null }).where(eq(companySecrets.id, secret.id)); + await expect(service.checkHealth(connection!.id, { actorType: "user", actorId: "alice" })) + .rejects.toMatchObject({ details: { code: "grant_credential_invalid", connection: { healthStatus: "missing_secret" } } }); + await expect(service.refreshCatalog(connection!.id, { actorType: "user", actorId: "alice" })) + .rejects.toMatchObject({ details: { code: "grant_credential_invalid" } }); + const otherCompany = await createCompany(db); + // The owner reconnects explicitly; no startup process adopts the old row. + await db.update(connectionGrants).set({ updatedAt: sql`'2026-09-30T12:00:00.123456Z'::timestamptz` }) + .where(eq(connectionGrants.id, grant!.id)); + const freshValue = "reconnected-fixture"; + if (reconnectMode === "inline") { + if (kind === "url" || kind === "zapier") { + await expect(service.reconnectGalleryApp(connection!.id, company.id, { + credentialValues: { "remote.url": "https://8.8.8.8/different-endpoint?token=rejected-fixture" }, + }, { actorType: "user", actorId: "alice" })) + .rejects.toMatchObject({ details: { code: "mcp_remote_url_credential_mismatch" } }); + } + const configPath = kind === "url" || kind === "zapier" ? "remote.url" : kind === "header" ? "headers.X-Api-Key" : "credentials.authorization"; + const reconnected = await service.reconnectGalleryApp(connection!.id, company.id, { + credentialValues: { [configPath]: kind === "url" || kind === "zapier" ? secretUrl.replace("fixture-canary", freshValue) : freshValue }, + }, { actorType: "user", actorId: "alice" }); + expect(reconnected.connection.id).toBe(connection!.id); + expect(reconnected.connection.healthStatus).toBe("ok"); + } else { + const reconnected = await service.connectGalleryApp(company.id, { + ...setupInput, reconnectConnectionId: connection!.id, + ...(kind === "zapier" || kind === "url" ? { link: secretUrl.replace("fixture-canary", freshValue) } + : { credentialValues: { [kind === "header" ? "headers.X-Api-Key" : "credentials.authorization"]: freshValue } }), + }, { actorType: "user", actorId: "alice" }); + expect(reconnected.connectionId).toBe(connection!.id); + await service.finishGalleryAppConnection(company.id, reconnected.connectionId, { + enabledCatalogEntryIds: reconnected.catalog.map((entry) => entry.id), + askFirstCatalogEntryIds: [], access: { agentIds: [agent.id] }, preserveExistingAccess: true, + }, { actorType: "user", actorId: "alice" }); + } + const [restoredGrant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant!.id)); + expect(restoredGrant).toMatchObject({ id: grant!.id, kind: "user", subjectUserId: "alice", status: "active" }); + activeSecretId = restoredGrant!.credentialSecretRefs[0]!.secretId; + expect(activeSecretId).not.toBe(secret.id); + const [freshSecret] = await db.select().from(companySecrets).where(eq(companySecrets.id, activeSecretId)); + expect(freshSecret).toMatchObject({ scope: "user", ownerUserId: "alice" }); + await expect(resolveConnectionGrantSecret(db, { ...connection!, companyId: otherCompany.id }, restoredGrant!, restoredGrant!.credentialSecretRefs[0]!, {})) + .rejects.toMatchObject({ details: { code: "grant_credential_invalid" } }); + expect(await db.select().from(userSecretDeclarations).where(eq(userSecretDeclarations.targetId, connection!.id))) + .toEqual([expect.objectContaining({ userSecretDefinitionId: freshSecret!.userSecretDefinitionId, + configPath: restoredGrant!.credentialSecretRefs[0]!.configPath })]); + expect(await db.select().from(companySecretBindings).where(eq(companySecretBindings.targetId, connection!.id))).toEqual([]); + expect((await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id)))[0]) + .toMatchObject({ scope: "company", ownerUserId: null, userSecretDefinitionId: null }); + calls.length = 0; + const restoredSession = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const restoredTools = (await gateway.listToolsForSession(restoredSession.token)).filter((tool) => tool.providerType === "mcp_remote_http"); + expect(restoredTools).toHaveLength(2); + for (const tool of restoredTools) { + await expect(gateway.executeTool({ sessionToken: restoredSession.token, tool: tool.name, parameters: { verification: "after-reconnect" } })) + .resolves.toMatchObject({ status: "completed", result: { content: "performed" } }); + } + const restoredCalls = calls.filter((call) => call.method === "tools/call"); + expect(restoredCalls).toHaveLength(2); + for (const call of restoredCalls) { + if (kind === "zapier" || kind === "url") expect(call.url).toBe(secretUrl.replace("fixture-canary", freshValue)); + if (kind === "bearer") expect(call.headers.get("authorization")).toBe(`Bearer ${freshValue}`); + if (kind === "header") expect(call.headers.get("x-api-key")).toBe(freshValue); + } + } + if (secret) { + const removed = await service.archiveConnection(connection!.id, company.id, { actorType: "user", actorId: "alice" }); + expect(removed.removal.secretsRevoked).toBe(1); + const [deleted] = await db.select().from(companySecrets).where(eq(companySecrets.id, activeSecretId!)); + expect(deleted?.status ?? "deleted").toBe("deleted"); + } + } + }, 30_000, + ); + + it.each(["header", "json"] as const)("returns an actionable insufficient-scope error from %s without exposing provider text", async (format) => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const { run } = await createIssueAndRun(db, company.id, agent.id); + const { connection } = await createRemoteMcpTool(db, company.id, { url: "https://8.8.8.8/mcp", riskLevel: "write" }); + await allowAllToolsForAgent(db, company.id, agent.id); + const gateway = createTestToolGatewayService(db, { + remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }], + remoteHttpRequest: async () => Response.json({ error: "insufficient_scope", message: "provider-secret-canary" }, { + status: 403, headers: format === "header" ? { "www-authenticate": 'Bearer error="insufficient_scope"' } : {}, + }), + }); + const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const tool = (await gateway.listToolsForSession(session.token)).find((entry) => entry.providerType === "mcp_remote_http")!; + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: { key: "key", value: "value" } })) + .rejects.toMatchObject({ status: 403, reasonCode: "oauth_insufficient_scope", message: expect.stringContaining("Reconnect") }); + expect(JSON.stringify(await db.select().from(toolAccessAuditEvents))).not.toContain("provider-secret-canary"); + const [updated] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id)); + expect(updated!.healthStatus).toBe("degraded"); + }); + it("uses the responsible user's identity directly and requires delegation only without one", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); diff --git a/server/src/services/connection-credential-bindings.ts b/server/src/services/connection-credential-bindings.ts index b6fb5a9aa4..ad4ab4b1ee 100644 --- a/server/src/services/connection-credential-bindings.ts +++ b/server/src/services/connection-credential-bindings.ts @@ -2,7 +2,7 @@ import { and, eq, inArray } from "drizzle-orm"; import { type Db, toolConnections, companySecretBindings, connectionGrants, companySecrets, userSecretDefinitions } from "@paperclipai/db"; import type { ToolCredentialSecretRef } from "@paperclipai/shared"; import { secretService } from "./secrets.js"; -function credentialRefConfigPath(ref: { name: string }): string { return ref.name.startsWith("credentials.") ? ref.name : `credentials.${ref.name}`; } +import { connectionCredentialConfigPath as credentialRefConfigPath } from "./connection-credentials.js"; export async function syncConnectionCredentialBindings( db: Db | Parameters[0]>[0], connection: typeof toolConnections.$inferSelect, diff --git a/server/src/services/connection-credentials.ts b/server/src/services/connection-credentials.ts new file mode 100644 index 0000000000..ed87874a85 --- /dev/null +++ b/server/src/services/connection-credentials.ts @@ -0,0 +1,167 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, inArray, isNull, ne, or } from "drizzle-orm"; +import { companySecrets, companySecretProposals, managedAgentProfiles, routineTriggers, userSecretDefinitions, type connectionGrants, type toolConnections } from "@paperclipai/db"; +import type { ToolCredentialSecretRef } from "@paperclipai/shared"; +import { connectionCredentialConfigPath } from "@paperclipai/shared"; +import { unprocessable } from "../errors.js"; +import { secretService } from "./secrets.js"; + +export { connectionCredentialConfigPath } from "@paperclipai/shared"; + +type CredentialDb = Parameters[0]; +type SecretActor = Parameters["create"]>[2]; +type ConsumerContext = NonNullable["resolveUserSecretValue"]>[2]>; + +/** These consumers reference secrets directly, without connection bindings. */ +export async function connectionSecretsUsedByOtherConsumers(db: CredentialDb, secretIds: string[]) { + if (!secretIds.length) return new Set(); + const profiles = await db.select({ secretId: managedAgentProfiles.apiKeySecretId }).from(managedAgentProfiles) + .where(inArray(managedAgentProfiles.apiKeySecretId, secretIds)); + const triggers = await db.select({ secretId: routineTriggers.secretId }).from(routineTriggers) + .where(inArray(routineTriggers.secretId, secretIds)); + const proposals = await db.select({ secretId: companySecretProposals.secretId, createdSecretId: companySecretProposals.createdSecretId }) + .from(companySecretProposals).where(or(inArray(companySecretProposals.secretId, secretIds), inArray(companySecretProposals.createdSecretId, secretIds))); + return new Set([...profiles, ...triggers, ...proposals, ...proposals.map((row) => ({ secretId: row.createdSecretId }))] + .flatMap((row) => row.secretId ? [row.secretId] : [])); +} + +export function connectionGrantCredentialRef( + grant: Pick, + ref: { name: string }, +): ToolCredentialSecretRef | undefined { + return grant.credentialSecretRefs.find((candidate) => + candidate.configPath === ref.name || candidate.configPath === connectionCredentialConfigPath(ref), + ); +} + +/** Create/replace invocation credentials in the selected identity's vault. */ +export async function writeConnectionCredential( + db: CredentialDb, + input: { + companyId: string; + connectionName: string; + configPath: string; + label: string; + value: string; + ownerUserId?: string | null; + existingRef?: ToolCredentialSecretRef; + /** OAuth shares one definition per connection/path, with one value per owner. */ + definitionKey?: string; + actor?: SecretActor; + }, +) { + const vault = secretService(db); + if (input.existingRef) { + const [existing] = await db.select().from(companySecrets).where(and( + eq(companySecrets.id, input.existingRef.secretId), + eq(companySecrets.companyId, input.companyId), + )).limit(1); + if (existing?.scope === "user" && existing.ownerUserId !== input.ownerUserId) { + throw unprocessable("Reconnect this connection as its credential owner.", { code: "grant_credential_invalid" }); + } + if (existing?.status === "active" && (input.ownerUserId + ? existing.scope === "user" && Boolean(existing.userSecretDefinitionId) + : existing.scope === "company")) { + const secret = input.ownerUserId + ? await vault.rotateCurrentUserSecretValue(input.companyId, input.ownerUserId, existing.id, { value: input.value }, input.actor) + : await vault.rotate(existing.id, { value: input.value }, input.actor); + return { secret, created: false, definitionId: null }; + } + // Re-entering a credential is fresh consent. Replace a legacy company + // secret with a new owner value; never rotate or adopt the old shared row. + } + const metadata = { + name: `${input.connectionName} ${input.label} ${randomUUID().slice(0, 8)}`, + key: `tool_app.${randomUUID()}.${input.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`, + provider: "local_encrypted" as const, + description: `Credential for ${input.connectionName} (${input.configPath}).`, + }; + if (!input.ownerUserId) { + return { secret: await vault.create(input.companyId, { ...metadata, value: input.value }, input.actor), created: true, definitionId: null }; + } + let definition = input.definitionKey ? (await db.select().from(userSecretDefinitions).where(and( + eq(userSecretDefinitions.companyId, input.companyId), eq(userSecretDefinitions.key, input.definitionKey), + isNull(userSecretDefinitions.deletedAt), + )).limit(1))[0] : undefined; + let createdDefinitionId: string | null = null; + if (!definition) { + if (input.definitionKey) { + [definition] = await db.insert(userSecretDefinitions).values({ + ...metadata, key: input.definitionKey, companyId: input.companyId, + managedMode: "paperclip_managed", createdByUserId: input.actor?.userId, createdByAgentId: input.actor?.agentId, + }).onConflictDoNothing().returning(); + if (definition) createdDefinitionId = definition.id; + else [definition] = await db.select().from(userSecretDefinitions).where(and( + eq(userSecretDefinitions.companyId, input.companyId), eq(userSecretDefinitions.key, input.definitionKey), + isNull(userSecretDefinitions.deletedAt), + )).limit(1); + } else { + definition = await vault.createUserSecretDefinition(input.companyId, metadata, input.actor); + createdDefinitionId = definition.id; + } + } + if (!definition) throw unprocessable("Reconnect this connection to restore its credential definition.", { code: "grant_credential_invalid" }); + const [ownerValue] = await db.select().from(companySecrets).where(and( + eq(companySecrets.companyId, input.companyId), eq(companySecrets.scope, "user"), + eq(companySecrets.ownerUserId, input.ownerUserId), eq(companySecrets.userSecretDefinitionId, definition.id), + ne(companySecrets.status, "deleted"), + )).limit(1); + if (ownerValue) { + if (ownerValue.status !== "active") await vault.updateCurrentUserSecretValue(input.companyId, input.ownerUserId, ownerValue.id, + { status: "active" }, input.actor); + return { secret: await vault.rotateCurrentUserSecretValue(input.companyId, input.ownerUserId, ownerValue.id, + { value: input.value }, input.actor), created: false, definitionId: createdDefinitionId }; + } + const secret = await vault.createCurrentUserSecretValue(input.companyId, input.ownerUserId, + { definitionId: definition.id, value: input.value }, input.actor); + return { secret, created: true, definitionId: createdDefinitionId }; +} + +/** Validate metadata without reading secret values, including for connection pickers. */ +export async function validateConnectionGrantSecretOwnership( + db: CredentialDb, + connection: Pick, + grant: Pick, + ref: ToolCredentialSecretRef, +) { + const [secret] = await db.select().from(companySecrets).where(and( + eq(companySecrets.id, ref.secretId), eq(companySecrets.companyId, connection.companyId), + )).limit(1); + const personal = grant.kind === "user"; + if (grant.companyId !== connection.companyId || grant.connectionId !== connection.id || !secret + || (personal + ? !grant.subjectUserId || secret.scope !== "user" || secret.ownerUserId !== grant.subjectUserId || !secret.userSecretDefinitionId + : secret.scope !== "company")) { + throw unprocessable("This connection's credential does not belong to the selected identity. Its owner must reconnect it.", { + code: "grant_credential_invalid", connectionId: connection.id, grantId: grant.id, credential: ref.configPath, + }); + } + return secret; +} + +/** The same owner/declaration checks apply during setup, testing and execution. */ +export async function resolveConnectionGrantSecret( + db: CredentialDb, + connection: Pick, + grant: Pick, + ref: ToolCredentialSecretRef, + context: ConsumerContext, +) { + const secret = await validateConnectionGrantSecretOwnership(db, connection, grant, ref); + const personal = grant.kind === "user"; + const accessContext = { ...context, consumerType: "tool_connection" as const, consumerId: connection.id, + configPath: ref.configPath, responsibleUserId: grant.subjectUserId }; + const vault = secretService(db); + if (!personal) { + return { value: await vault.resolveSecretValue(connection.companyId, ref.secretId, ref.versionSelector ?? "latest", + { accessContext }), latestVersion: secret.latestVersion }; + } + const resolved = await vault.resolveUserSecretValue(connection.companyId, { + definitionId: secret.userSecretDefinitionId!, responsibleUserId: grant.subjectUserId!, + version: ref.versionSelector ?? "latest", required: ref.required ?? true, + }, accessContext); + if (!resolved) throw unprocessable("Your credential is missing. Reconnect this connection.", { + code: "user_secret_missing", connectionId: connection.id, grantId: grant.id, credential: ref.configPath, + }); + return { value: resolved.value, latestVersion: secret.latestVersion }; +} diff --git a/server/src/services/connection-permission-errors.ts b/server/src/services/connection-permission-errors.ts new file mode 100644 index 0000000000..465dfe82f4 --- /dev/null +++ b/server/src/services/connection-permission-errors.ts @@ -0,0 +1,12 @@ +/** Only explicit protocol error codes establish insufficient consent. Never echo provider bodies. */ +export function isInsufficientConnectionScope(response: Pick, body?: string): boolean { + if (response.status !== 401 && response.status !== 403) return false; + if (/\berror\s*=\s*"?insufficient_scope\b/i.test(response.headers.get("www-authenticate") ?? "")) return true; + if (!body) return false; + try { + const payload = JSON.parse(body); + return payload?.error === "insufficient_scope" || payload?.error?.code === "insufficient_scope" + || payload?.error?.data?.code === "insufficient_scope"; + } catch { return false; } +} +export const INSUFFICIENT_CONNECTION_SCOPE_MESSAGE = "The provider has not granted the permissions needed for this action. Reconnect this connection and allow the required read and write access."; diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 94127b0680..880f66e956 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -1,3 +1,4 @@ +import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js"; import { BROWSER_USE_TOOLS } from "@paperclipai/shared"; import { browserUseClient, isBrowserUseConnection } from "./browser-use-client.js"; import { browserUseService } from "./browser-use.js"; @@ -44,6 +45,7 @@ import { companySecrets, principalPermissionGrants, userSecretDefinitions, + userSecretDeclarations, heartbeatRuns, issues, issueThreadInteractions, @@ -219,6 +221,7 @@ import { splitRemoteUrlCredential, } from "./remote-url-credentials.js"; import { secretService } from "./secrets.js"; +import { connectionCredentialConfigPath as credentialRefConfigPath, connectionGrantCredentialRef, connectionSecretsUsedByOtherConsumers, resolveConnectionGrantSecret, validateConnectionGrantSecretOwnership, writeConnectionCredential } from "./connection-credentials.js"; import { agentmailApi } from "./agentmail-api.js"; import type { ConfigureRailwaySsh, RailwaySshSetup } from "@paperclipai/shared"; import { generateRailwaySshKey, RAILWAY_SSH_SECRET_PATH, validateRailwayKnownHosts } from "./railway-ssh.js"; @@ -1057,11 +1060,6 @@ function credentialFieldsFor(app: AppDefinition, methodKey?: string | null) { })); } -function credentialRefConfigPath(ref: { name: string }): string { - return ref.name.startsWith("credentials.") - ? ref.name - : `credentials.${ref.name}`; -} export function normalizeConnectionMethodConfig( method: ConnectionMethodDef, @@ -2866,6 +2864,9 @@ function sanitizeHttpFailure(error: unknown): { if (code === "cognee_access_unverified" || code === "memory_api_key_rejected") { return { status: "error", message: error.message, code }; } + if (code === "grant_credential_invalid" || code === "oauth_insufficient_scope") { + return { status: code === "grant_credential_invalid" ? "missing_secret" : "degraded", message: error.message, code }; + } if (code === "slack_mcp_access_disabled") { return { status: "error", message: error.message, code }; } @@ -3002,9 +3003,20 @@ async function gitHubReadGrantAccess(db: Db, companyId: string, connectionId: st if (!localTrusted && (!membership || membership.membershipRole === "viewer")) throw forbidden("GitHub access requires an active company member."); const grants = await db.select().from(connectionGrants).where(and(eq(connectionGrants.companyId, companyId), eq(connectionGrants.connectionId, connectionId))); const members = await db.select().from(connectionGrantMembers).where(eq(connectionGrantMembers.companyId, companyId)); - const allowed = grants.filter(grant => !(grant.kind === "organization" && ["per_user", "per_agent"].includes(connection.credentialPolicy)) && canBrowseProjectRepositoryGrant({ + const candidates = grants.filter(grant => !(grant.kind === "organization" && ["per_user", "per_agent"].includes(connection.credentialPolicy)) && canBrowseProjectRepositoryGrant({ grant, userId, activeMember: localTrusted || Boolean(membership), audience: members.filter(member => member.grantId === grant.id).map(member => member.subjectId), })); + const allowed: typeof candidates = []; + for (const grant of candidates) { + const ref = grant.credentialSecretRefs.find(ref => ref.configPath === "oauth.access_token" || /authorization|token|api_key/i.test(ref.configPath)); + if (!ref) continue; + try { + await validateConnectionGrantSecretOwnership(db, connection, grant, ref); + allowed.push(grant); + } catch (error) { + if (!(error instanceof HttpError && asRecord(error.details).code === "grant_credential_invalid")) throw error; + } + } const legacyShared = !grants.length && connection.credentialPolicy === "shared"; if (!allowed.length && !legacyShared) throw forbidden("Choose a GitHub connection with an active authorization you can use."); return { connection, allowed, legacyShared }; @@ -3027,27 +3039,23 @@ export function toolAccessService( if (!ref) return publicEndpoint; let value: string; try { - value = await secrets.resolveSecretValue( - connection.companyId, - ref.secretId, - ref.version ?? "latest", - { - consumerType: "tool_connection", - consumerId: connection.id, - configPath: REMOTE_URL_SECRET_CONFIG_PATH, - actorType: actor?.actorType ?? "system", - actorId: actor?.actorId ?? null, - }, - ); - } catch { - throw unprocessable( - "A configured credential secret could not be resolved.", - { - code: "mcp_remote_missing_secret", - connectionId: connection.id, - credential: REMOTE_URL_SECRET_CONFIG_PATH, - }, - ); + const grant = await vaultGrantForConnection(connection, actor); + const grantRef = grant ? connectionGrantCredentialRef(grant, ref) : undefined; + if (grant && !grantRef) throw unprocessable("Your MCP URL credential is missing. Reconnect this connection.", { + code: "grant_credential_invalid", connectionId: connection.id, grantId: grant.id, + }); + value = grant && grantRef + ? (await resolveOAuthGrantSecret(connection, grant, grantRef, actor, undefined)).value + : await secrets.resolveSecretValue(connection.companyId, ref.secretId, ref.version ?? "latest", { + consumerType: "tool_connection", consumerId: connection.id, + configPath: REMOTE_URL_SECRET_CONFIG_PATH, + actorType: actor?.actorType ?? "system", actorId: actor?.actorId ?? null, + }); + } catch (error) { + if (error instanceof HttpError) throw error; + throw unprocessable("Your MCP URL credential could not be resolved. Reconnect this connection.", { + code: "mcp_remote_missing_secret", connectionId: connection.id, credential: REMOTE_URL_SECRET_CONFIG_PATH, + }); } if (!remoteUrlCredentialMatchesPublicUrl(publicEndpoint, value)) { throw unprocessable( @@ -5843,150 +5851,7 @@ export function toolAccessService( grantSecretRefs: ToolCredentialSecretRef[] = [], dbClient: ToolAccessMutationDb = db, ) { - await dbClient - .delete(companySecretBindings) - .where( - and( - eq(companySecretBindings.companyId, connection.companyId), - eq(companySecretBindings.targetType, "tool_connection"), - eq(companySecretBindings.targetId, connection.id), - ), - ); - // A metadata edit or pause/resume must retain declarations for every - // active personal/dedicated grant, not just connection-owned credentials. - const activeGrants = await dbClient - .select({ refs: connectionGrants.credentialSecretRefs }) - .from(connectionGrants) - .where( - and( - eq(connectionGrants.companyId, connection.companyId), - eq(connectionGrants.connectionId, connection.id), - eq(connectionGrants.status, "active"), - ), - ); - const rawBindings = [ - ...connection.credentialRefs.map((ref) => ({ - secretId: ref.secretId, - configPath: credentialRefConfigPath(ref), - projectionClass: "unclassified", - projectionAllowlistKey: null, - required: true, - label: null, - })), - ...[ - ...connection.credentialSecretRefs, - ...grantSecretRefs, - ...activeGrants.flatMap((grant) => grant.refs), - ].map((ref) => ({ - secretId: ref.secretId, - configPath: ref.configPath, - projectionClass: ref.projectionClass ?? "unclassified", - projectionAllowlistKey: ref.projectionAllowlistKey ?? null, - required: ref.required ?? true, - label: ref.label ?? null, - })), - ]; - // Organization grants can mirror connection-owned credentials, and more - // than one personal grant can reference the same client registration. - // Binding rows are unique per secret/config path, so collapse those mirrors - // before replacing the durable projection declarations. - const bindings = [ - ...new Map( - rawBindings.map((ref) => [`${ref.secretId}:${ref.configPath}`, ref]), - ).values(), - ]; - const secretRows = - bindings.length > 0 - ? await dbClient - .select({ - id: companySecrets.id, - scope: companySecrets.scope, - userSecretDefinitionId: companySecrets.userSecretDefinitionId, - }) - .from(companySecrets) - .where( - and( - eq(companySecrets.companyId, connection.companyId), - inArray(companySecrets.id, [ - ...new Set(bindings.map((ref) => ref.secretId)), - ]), - ), - ) - : []; - const secretById = new Map(secretRows.map((row) => [row.id, row])); - const definitionIds = [ - ...new Set( - secretRows.flatMap((row) => - row.userSecretDefinitionId ? [row.userSecretDefinitionId] : [], - ), - ), - ]; - const definitions = - definitionIds.length > 0 - ? await dbClient - .select({ - id: userSecretDefinitions.id, - key: userSecretDefinitions.key, - }) - .from(userSecretDefinitions) - .where( - and( - eq(userSecretDefinitions.companyId, connection.companyId), - inArray(userSecretDefinitions.id, definitionIds), - ), - ) - : []; - const definitionKeyById = new Map( - definitions.map((row) => [row.id, row.key]), - ); - const userDeclarations = [ - ...new Map( - bindings - .flatMap((ref) => { - const secret = secretById.get(ref.secretId); - const definitionKey = - secret?.scope === "user" && secret.userSecretDefinitionId - ? definitionKeyById.get(secret.userSecretDefinitionId) - : null; - return definitionKey - ? [ - { - definitionKey, - configPath: ref.configPath, - envKey: ref.configPath, - versionSelector: "latest" as const, - required: ref.required, - label: ref.label, - }, - ] - : []; - }) - .map((ref) => [`${ref.definitionKey}:${ref.configPath}`, ref]), - ).values(), - ]; - await secrets.syncUserSecretDeclarationsForTarget( - connection.companyId, - { targetType: "tool_connection", targetId: connection.id }, - userDeclarations, - { replaceAll: true, db: dbClient }, - ); - const companyBindings = bindings.filter( - (ref) => secretById.get(ref.secretId)?.scope !== "user", - ); - if (companyBindings.length === 0) return; - await dbClient.insert(companySecretBindings).values( - companyBindings.map((ref) => ({ - companyId: connection.companyId, - secretId: ref.secretId, - targetType: "tool_connection" as const, - targetId: connection.id, - configPath: ref.configPath, - required: ref.required, - label: ref.label, - projectionClass: ref.projectionClass, - projectionAllowlistKey: ref.projectionAllowlistKey, - })), - ); + await syncConnectionCredentialBindings(db, connection, grantSecretRefs, dbClient); } /** @@ -5998,8 +5863,8 @@ export function toolAccessService( * target. Two independent tests have to agree before a secret is destroyed: * * 1. Provenance — the key sits in the `tool_app.` namespace only the - * connect/reconnect/OAuth paths mint, and the row is a company-scoped - * Paperclip secret rather than a per-user credential. + * connect/reconnect/OAuth paths mint, or its user-secret definition does. + * Personal definitions must also have no declarations on other targets. * 2. Exclusivity — nothing outside this connection references it: no * `company_secret_bindings` row from another target, and no other * connection or connection grant naming the same secret id. @@ -6037,7 +5902,7 @@ export function toolAccessService( ); const byId = new Map(secretRows.map((row) => [row.id, row])); - const referencedElsewhere = new Set(); + const referencedElsewhere = await connectionSecretsUsedByOtherConsumers(db, unique); const foreignBindings = await db .select({ secretId: companySecretBindings.secretId }) .from(companySecretBindings) @@ -6085,6 +5950,15 @@ export function toolAccessService( referencedElsewhere.add(ref.secretId); } + const definitionIds = secretRows.flatMap((row) => row.userSecretDefinitionId ? [row.userSecretDefinitionId] : []); + const definitions = definitionIds.length ? await db.select().from(userSecretDefinitions) + .where(and(eq(userSecretDefinitions.companyId, connection.companyId), inArray(userSecretDefinitions.id, definitionIds))) : []; + const dedicatedDefinitions = new Set(definitions.filter((row) => row.key.startsWith(CONNECTION_OWNED_SECRET_KEY_PREFIX) || row.key.startsWith(`tool_oauth.${connection.id}.`)).map((row) => row.id)); + const foreignDeclarations = definitionIds.length ? await db.select().from(userSecretDeclarations).where(and( + inArray(userSecretDeclarations.userSecretDefinitionId, definitionIds), + sql`not (${userSecretDeclarations.targetType} = 'tool_connection' and ${userSecretDeclarations.targetId} = ${connection.id})`, + )) : []; + const sharedDefinitions = new Set(foreignDeclarations.map((row) => row.userSecretDefinitionId)); const owned: string[] = []; const retained: string[] = []; for (const secretId of unique) { @@ -6096,10 +5970,10 @@ export function toolAccessService( owned.push(secretId); continue; } - const dedicated = - row.scope === "company" && - row.userSecretDefinitionId === null && - row.key.startsWith(CONNECTION_OWNED_SECRET_KEY_PREFIX); + const dedicated = row.scope === "user" && row.userSecretDefinitionId + ? dedicatedDefinitions.has(row.userSecretDefinitionId) && !sharedDefinitions.has(row.userSecretDefinitionId) + : row.scope === "company" && row.userSecretDefinitionId === null + && row.key.startsWith(CONNECTION_OWNED_SECRET_KEY_PREFIX); if (dedicated && !referencedElsewhere.has(secretId)) owned.push(secretId); else retained.push(secretId); } @@ -6624,6 +6498,16 @@ export function toolAccessService( .limit(2); if (personalGrants.length === 1) return personalGrants[0]!; } + if (connection.credentialPolicy === "per_user") { + throw unprocessable( + "This connection needs the current user's authorization", + { + code: "user_authorization_required", + setupUrl: connectionSetupUrl(connection), + reconnectUrl: connectionReconnectUrl(connection), + }, + ); + } const [organization] = await db .select() .from(connectionGrants) @@ -6637,16 +6521,6 @@ export function toolAccessService( ) .limit(1); if (organization?.status === "active") return organization; - if (connection.credentialPolicy === "per_user") { - throw unprocessable( - "This connection needs the current user's authorization", - { - code: "user_authorization_required", - setupUrl: connectionSetupUrl(connection), - reconnectUrl: connectionReconnectUrl(connection), - }, - ); - } return null; } @@ -6703,12 +6577,14 @@ export function toolAccessService( const headers: Record = {}; const scope = credentialScope(connection); for (const ref of connection.credentialRefs) { + if (ref.placement !== "header") continue; let value: string; const configPath = credentialRefConfigPath(ref); try { - const grantRef = grant?.credentialSecretRefs.find( - (candidate) => candidate.configPath === configPath, - ); + const grantRef = grant ? connectionGrantCredentialRef(grant, ref) : undefined; + if (grant && !grantRef) throw unprocessable("Your credential is missing. Reconnect this connection.", { + code: "grant_credential_invalid", connectionId: connection.id, grantId: grant.id, credential: configPath, + }); value = grantRef && grant ? ( @@ -6876,6 +6752,10 @@ export function toolAccessService( ), ); } + if (isInsufficientConnectionScope(response)) { + await response.body?.cancel().catch(() => undefined); + throw unprocessable(INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, { code: "oauth_insufficient_scope", connectionId: connection.id }); + } if ( response.status === 401 && connection.credentialSource === "vercel_connect" @@ -9204,147 +9084,24 @@ export function toolAccessService( ownerUserId?: string; }, context?: { - dbClient: ToolAccessMutationDb; + dbClient: Db | DbTransaction; secretClient: ReturnType; }, ) { - const dbClient = context?.dbClient ?? db; - const secretClient = context?.secretClient ?? secrets; - const existing = - input.existingRefs === undefined - ? oauthSecretRef(input.connection, input.configPath) - : input.existingRefs.find((ref) => ref.configPath === input.configPath); - if (existing) { - await secretClient.rotate( - existing.secretId, - { value: input.value }, - actorForSecret(input.actor), - ); - return existing; - } - if (input.ownerUserId) { - const definitionKey = `tool_oauth.${input.connection.id}.${input.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`; - let [definition] = await dbClient - .select() - .from(userSecretDefinitions) - .where( - and( - eq(userSecretDefinitions.companyId, input.companyId), - eq(userSecretDefinitions.key, definitionKey), - isNull(userSecretDefinitions.deletedAt), - ), - ) - .limit(1); - if (!definition) { - [definition] = await dbClient - .insert(userSecretDefinitions) - .values({ - companyId: input.companyId, - key: definitionKey, - name: `${input.connection.name} ${input.label}`, - description: `Personal OAuth ${input.label.toLowerCase()} for ${input.connection.name}.`, - provider: "local_encrypted", - managedMode: "paperclip_managed", - createdByAgentId: - input.actor?.actorType === "agent" ? input.actor.actorId : null, - createdByUserId: - input.actor?.actorType === "user" ? input.actor.actorId : null, - }) - .onConflictDoNothing() - .returning(); - if (!definition) { - [definition] = await dbClient - .select() - .from(userSecretDefinitions) - .where( - and( - eq(userSecretDefinitions.companyId, input.companyId), - eq(userSecretDefinitions.key, definitionKey), - isNull(userSecretDefinitions.deletedAt), - ), - ) - .limit(1); - } - } - if (!definition) - throw new Error("Failed to create personal OAuth secret definition"); - const [existingUserValue] = await dbClient - .select() - .from(companySecrets) - .where( - and( - eq(companySecrets.companyId, input.companyId), - eq(companySecrets.scope, "user"), - eq(companySecrets.ownerUserId, input.ownerUserId), - eq(companySecrets.userSecretDefinitionId, definition.id), - ne(companySecrets.status, "deleted"), - ), - ) - .limit(1); - if (existingUserValue) { - // A removed/revoked grant can predate credential cleanup and therefore - // lose its ref while its deterministic owner value remains. Reconnect - // is explicit fresh consent, so revive that owner-bound value and - // rotate it instead of colliding with the one-value-per-definition - // constraint. - if (existingUserValue.status !== "active") { - await secretClient.updateCurrentUserSecretValue( - input.companyId, - input.ownerUserId, - existingUserValue.id, - { status: "active" }, - actorForSecret(input.actor), - ); - } - const secret = await secretClient.rotateCurrentUserSecretValue( - input.companyId, - input.ownerUserId, - existingUserValue.id, - { value: input.value }, - actorForSecret(input.actor), - ); - return { - secretId: secret.id, - versionSelector: "latest" as const, - configPath: input.configPath, - required: input.configPath === "oauth.access_token", - label: input.label, - }; - } - const secret = await secretClient.createCurrentUserSecretValue( - input.companyId, - input.ownerUserId, - { - definitionId: definition.id, - value: input.value, - }, - actorForSecret(input.actor), - ); - return { - secretId: secret.id, - versionSelector: "latest" as const, - configPath: input.configPath, - required: input.configPath === "oauth.access_token", - label: input.label, - }; - } - const secret = await secretClient.create( - input.companyId, - { - name: `${input.connection.name} ${input.label} ${randomUUID().slice(0, 8)}`, - key: `tool_app.${randomUUID()}.${input.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`, - provider: "local_encrypted", - value: input.value, - description: `OAuth ${input.label.toLowerCase()} for ${input.connection.name}.`, - }, - actorForSecret(input.actor), - ); + const existing = input.existingRefs === undefined + ? oauthSecretRef(input.connection, input.configPath) + : input.existingRefs.find((ref) => ref.configPath === input.configPath); + const write = (client: Db | DbTransaction) => writeConnectionCredential(client, { + companyId: input.companyId, connectionName: input.connection.name, configPath: input.configPath, + label: input.label, value: input.value, existingRef: existing ?? undefined, actor: actorForSecret(input.actor), + // Registration authenticates the OAuth client, never the person invoking tools. + ownerUserId: input.configPath === "oauth.client_secret" ? undefined : input.ownerUserId, + definitionKey: input.ownerUserId ? `tool_oauth.${input.connection.id}.${input.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}` : undefined, + }); + const { secret } = context ? await write(context.dbClient) : await db.transaction(write); return { - secretId: secret.id, - versionSelector: "latest" as const, - configPath: input.configPath, - required: input.configPath === "oauth.access_token", - label: input.label, + secretId: secret.id, versionSelector: "latest" as const, configPath: input.configPath, + required: input.configPath === "oauth.access_token", label: input.label, }; } @@ -10795,74 +10552,11 @@ export function toolAccessService( accessContext: { issueId?: string | null; heartbeatRunId?: string | null } | undefined, ) { - const [secret] = await db - .select({ - scope: companySecrets.scope, - ownerUserId: companySecrets.ownerUserId, - userSecretDefinitionId: companySecrets.userSecretDefinitionId, - latestVersion: companySecrets.latestVersion, - }) - .from(companySecrets) - .where( - and( - eq(companySecrets.id, ref.secretId), - eq(companySecrets.companyId, connection.companyId), - ), - ) - .limit(1); - if (!secret) throw notFound("OAuth credential secret not found"); - const consumerContext = { - consumerType: "tool_connection" as const, - consumerId: connection.id, - configPath: ref.configPath, - actorType: actor?.actorType ?? ("system" as const), - actorId: actor?.actorId ?? null, - responsibleUserId: grant.subjectUserId, - issueId: accessContext?.issueId, - heartbeatRunId: accessContext?.heartbeatRunId, - }; - if (secret.scope !== "user") { - return { - value: await secrets.resolveSecretValue( - connection.companyId, - ref.secretId, - ref.versionSelector ?? "latest", - consumerContext, - ), - latestVersion: secret.latestVersion, - }; - } - if ( - grant.kind !== "user" || - !grant.subjectUserId || - secret.ownerUserId !== grant.subjectUserId || - !secret.userSecretDefinitionId - ) { - throw unprocessable("Personal authorization has an invalid credential", { - code: "grant_credential_invalid", - connectionId: connection.id, - grantId: grant.id, - credential: ref.configPath, - }); - } - const resolved = await secrets.resolveUserSecretValue( - connection.companyId, - { - definitionId: secret.userSecretDefinitionId, - responsibleUserId: grant.subjectUserId, - version: ref.versionSelector ?? "latest", - required: ref.required ?? true, - }, - consumerContext, - ); - if (!resolved) - throw unprocessable("Personal OAuth credential is not configured", { - code: "user_secret_missing", - connectionId: connection.id, - grantId: grant.id, - credential: ref.configPath, - }); - return { value: resolved.value, latestVersion: secret.latestVersion }; + return resolveConnectionGrantSecret(db, connection, grant, ref, { + consumerType: "tool_connection", consumerId: connection.id, + actorType: actor?.actorType ?? "system", actorId: actor?.actorId ?? null, + issueId: accessContext?.issueId, heartbeatRunId: accessContext?.heartbeatRunId, + }); } async function clearOAuthGrantRefreshLease( @@ -12589,28 +12283,13 @@ export function toolAccessService( throw badRequest(`Missing credential value for ${field.configPath}`); } if (!value) continue; - const secretMetadata = { - name: `${name} ${field.label} ${randomUUID().slice(0, 8)}`, - key: `tool_app.${randomUUID()}.${field.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`, - provider: "local_encrypted" as const, - description: `Credential for ${name} (${field.configPath}).`, - }; - // A personal grant must own a user-scoped vault value. Merely keeping a - // company secret off the connection row does not establish ownership. - const secret = personalIdentityUserId - ? await db.transaction(async (tx) => { - const personalSecrets = secretService(tx as unknown as Db); - const definition = await personalSecrets.createUserSecretDefinition( - companyId, secretMetadata, actorForSecret(actor), - ); - const valueRow = await personalSecrets.createCurrentUserSecretValue( - companyId, personalIdentityUserId, - { definitionId: definition.id, value }, actorForSecret(actor), - ); - createdDefinitionIds.push(definition.id); - return valueRow; - }) - : await secrets.create(companyId, { ...secretMetadata, value }, actorForSecret(actor)); + const written = await db.transaction((tx) => writeConnectionCredential(tx, { + companyId, connectionName: name, configPath: field.configPath, + label: field.label, value, ownerUserId: personalIdentityUserId, + actor: actorForSecret(actor), + })); + const { secret } = written; + if (written.definitionId) createdDefinitionIds.push(written.definitionId); createdSecretIds.push(secret.id); credentialSecretRefs.push({ secretId: secret.id, @@ -12639,17 +12318,13 @@ export function toolAccessService( } } if (remoteUrlCredential?.secretUrl) { - const secret = await secrets.create( - companyId, - { - name: `${name} MCP server URL ${randomUUID().slice(0, 8)}`, - key: `tool_app.${randomUUID()}.remote_url`, - provider: "local_encrypted", - value: remoteUrlCredential.secretUrl, - description: `Credential-bearing MCP server URL for ${name}.`, - }, - actorForSecret(actor), - ); + const written = await db.transaction((tx) => writeConnectionCredential(tx, { + companyId, connectionName: name, configPath: REMOTE_URL_SECRET_CONFIG_PATH, + label: "MCP server URL", value: remoteUrlCredential.secretUrl!, + ownerUserId: personalIdentityUserId, actor: actorForSecret(actor), + })); + const { secret } = written; + if (written.definitionId) createdDefinitionIds.push(written.definitionId); createdSecretIds.push(secret.id); credentialSecretRefs.push({ secretId: secret.id, @@ -12873,11 +12548,17 @@ export function toolAccessService( let changedGrant: typeof connectionGrants.$inferSelect; let previousGrant: typeof connectionGrants.$inferSelect | null = null; if (retainedPersonalIdentity?.grant) { - const [currentGrant] = await db - .select() + const [grantSnapshot] = await db + .select({ + grant: connectionGrants, + // Preserve PostgreSQL microseconds for the optimistic update; + // a JavaScript Date truncates them and falsely reports a race. + updatedAtVersion: sql`${connectionGrants.updatedAt}::text`, + }) .from(connectionGrants) .where(eq(connectionGrants.id, retainedPersonalIdentity.grant.id)) .limit(1); + const currentGrant = grantSnapshot?.grant; if (!currentGrant) throw conflict( "The personal credential changed during setup. Please try again.", @@ -12896,7 +12577,7 @@ export function toolAccessService( .where( and( eq(connectionGrants.id, currentGrant.id), - eq(connectionGrants.updatedAt, currentGrant.updatedAt), + sql`${connectionGrants.updatedAt} = ${grantSnapshot!.updatedAtVersion}::timestamptz`, ), ) .returning(); @@ -14011,12 +13692,23 @@ export function toolAccessService( const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null; - const credentialFields = galleryEntry - ? credentialFieldsFor( - galleryEntry, - connectionMethodForConnection(galleryEntry, connection).key, - ) - : [ + const storedCredentialFields = connection.credentialRefs + .filter((ref) => ref.placement === "header" || ref.placement === "url") + .map((ref) => ({ + label: ref.placement === "url" ? "MCP server URL" : ref.prefix === "Bearer " ? "App key" : ref.key ?? ref.name, + configPath: credentialRefConfigPath(ref), + helpUrl: "", + required: false, + placement: ref.placement, + key: ref.key, + prefix: ref.prefix, + })); + const galleryCredentialFields = galleryEntry + ? credentialFieldsFor(galleryEntry, connectionMethodForConnection(galleryEntry, connection).key) + : []; + const credentialFields = galleryCredentialFields.length > 0 + ? galleryCredentialFields + : storedCredentialFields.length > 0 ? storedCredentialFields : [ { label: "App key", configPath: "credentials.authorization", @@ -14033,7 +13725,7 @@ export function toolAccessService( (input.credentialValues[field.configPath]?.trim().length ?? 0) > 0, ); if (providedFields.length === 0) - throw badRequest("Paste a new key to reconnect this app"); + throw badRequest("Enter a replacement credential to reconnect this app"); const personalIdentity = await fixedPersonalIdentityForReconnect( connection, @@ -14048,59 +13740,45 @@ export function toolAccessService( ...(connection.credentialRefs ?? []), ]; - for (const field of providedFields) { - const value = input.credentialValues[field.configPath]!.trim(); - const existing = credentialSecretRefs.find( - (ref) => ref.configPath === field.configPath, - ); - if (existing) { - await secrets.rotate( - existing.secretId, - { value }, - actorForSecret(actor), - ); - continue; - } - const metadata = { - name: `${connection.name} ${field.label} ${randomUUID().slice(0, 8)}`, - key: `tool_app.${randomUUID()}.${field.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`, - provider: "local_encrypted" as const, - description: `Credential for ${connection.name} (${field.configPath}).`, - }; - const secret = personalIdentity - ? await db.transaction(async (tx) => { - const vault = secretService(tx as unknown as Db); - const definition = await vault.createUserSecretDefinition(companyId, metadata, actorForSecret(actor)); - return vault.createCurrentUserSecretValue(companyId, personalIdentity.subjectUserId, - { definitionId: definition.id, value }, actorForSecret(actor)); - }) - : await secrets.create(companyId, { ...metadata, value }, actorForSecret(actor)); - credentialSecretRefs.push({ - secretId: secret.id, - versionSelector: "latest", - configPath: field.configPath, - required: field.required ?? true, - label: field.label, - }); - if (field.placement === "header" && field.key) { - const nextCredentialRef = { - name: field.configPath, - secretId: secret.id, - version: "latest", - placement: "header", - key: field.key, - prefix: field.prefix ?? null, - } satisfies McpConnectionCredentialRef; - const existingCredentialRefIndex = credentialRefs.findIndex( - (ref) => ref.name === field.configPath, - ); - if (existingCredentialRefIndex >= 0) - credentialRefs[existingCredentialRefIndex] = nextCredentialRef; - else credentialRefs.push(nextCredentialRef); - } - } - const updated = await db.transaction(async (tx) => { + for (const field of providedFields) { + const value = input.credentialValues[field.configPath]!.trim(); + if (field.placement === "url" && !remoteUrlCredentialMatchesPublicUrl(String(connection.config.url ?? ""), value)) { + throw badRequest("The replacement server URL must use the same endpoint as this connection.", { + code: "mcp_remote_url_credential_mismatch", + }); + } + const existing = credentialSecretRefs.find( + (ref) => ref.configPath === field.configPath, + ); + const { secret } = await writeConnectionCredential(tx, { + companyId, connectionName: connection.name, configPath: field.configPath, + label: field.label, value, ownerUserId: personalIdentity?.subjectUserId, + existingRef: existing, actor: actorForSecret(actor), + }); + const nextRef = { secretId: secret.id, versionSelector: "latest" as const, + configPath: field.configPath, required: field.required ?? true, label: field.label }; + const refIndex = credentialSecretRefs.findIndex((ref) => ref.configPath === field.configPath); + if (refIndex >= 0) credentialSecretRefs[refIndex] = nextRef; + else credentialSecretRefs.push(nextRef); + if ((field.placement === "header" && field.key) || field.placement === "url") { + const nextCredentialRef = { + name: field.configPath, + secretId: secret.id, + version: "latest", + placement: field.placement, + key: field.key ?? "url", + prefix: field.prefix ?? null, + } satisfies McpConnectionCredentialRef; + const existingCredentialRefIndex = credentialRefs.findIndex( + (ref) => credentialRefConfigPath(ref) === field.configPath, + ); + if (existingCredentialRefIndex >= 0) + credentialRefs[existingCredentialRefIndex] = nextCredentialRef; + else credentialRefs.push(nextCredentialRef); + } + } + const updatedAt = new Date(); if (personalIdentity) { const grantValues = { @@ -14142,12 +13820,10 @@ export function toolAccessService( }) .where(eq(toolConnections.id, connection.id)) .returning(); + await syncConnectionCredentialBindings(tx, nextConnection, + personalIdentity ? credentialSecretRefs : []); return nextConnection; }); - await syncCredentialBindings( - updated, - personalIdentity ? credentialSecretRefs : [], - ); const health = await checkConnectionHealth(updated.id, actor); const refresh = await refreshCatalog(updated.id, actor, { enableAllByDefault: true, diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 8f6bd01db6..38ae316b31 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -1,3 +1,4 @@ +import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js"; import { browserUseService } from "./browser-use.js"; import { isBrowserUseConnection } from "./browser-use-client.js"; import { COGNEE_STDIO_TEMPLATE, cogneeCloudUrl, callCogneeCloud } from "./cognee-connection.js"; @@ -99,6 +100,7 @@ import type { } from "./plugin-tool-dispatcher.js"; import { logActivity, type LogActivityInput } from "./activity-log.js"; import { secretService } from "./secrets.js"; +import { connectionGrantCredentialRef, resolveConnectionGrantSecret } from "./connection-credentials.js"; import { railwayCommandBudgetMs, createRailwayClient, isRailwayConnection, isRailwayEndpoint, isRailwayToolBlocked, normalizeRailwayToolName, RAILWAY_API_URL, RAILWAY_TOOL_PREFIX, RailwayError } from "./railway.js"; import { RAILWAY_SSH_SECRET_PATH, runRailwaySshCommand } from "./railway-ssh.js"; import { @@ -3187,7 +3189,6 @@ export function createToolGatewayService( connection, grant, grantRef, - REMOTE_URL_SECRET_CONFIG_PATH, ); if (!remoteUrlCredentialMatchesPublicUrl(publicEndpoint, value)) { throw new ToolGatewayHttpError( @@ -3529,11 +3530,7 @@ export function createToolGatewayService( grant: typeof connectionGrants.$inferSelect, ref: McpConnectionCredentialRef, ): ToolCredentialSecretRef | undefined { - return grant.credentialSecretRefs.find( - (candidate) => - candidate.configPath === ref.name || - candidate.configPath === `credentials.${ref.name}`, - ); + return connectionGrantCredentialRef(grant, ref); } async function resolveGrantSecretValue( @@ -3541,91 +3538,18 @@ export function createToolGatewayService( connection: typeof toolConnections.$inferSelect, grant: typeof connectionGrants.$inferSelect, ref: ToolCredentialSecretRef, - configPath = ref.configPath, ): Promise { - const accessContext = { - consumerType: "tool_connection" as const, - consumerId: connection.id, - configPath, - actorType: "system" as const, - actorId: session.agentId, - responsibleUserId: grant.subjectUserId, - issueId: session.issueId, - heartbeatRunId: session.runId, - }; - if (grant.kind !== "user") { - return secrets.resolveSecretValue( - connection.companyId, - ref.secretId, - ref.versionSelector ?? "latest", - { accessContext }, - ); + try { + return (await resolveConnectionGrantSecret(db, connection, grant, ref, { + consumerType: "tool_connection", consumerId: connection.id, + actorType: "system", actorId: session.agentId, + responsibleUserId: grant.subjectUserId, issueId: session.issueId, heartbeatRunId: session.runId, + })).value; + } catch (error) { + if (error instanceof HttpError) throw new ToolGatewayHttpError(error.status, error.message, + String(asRecord(error.details)?.code ?? "mcp_remote_missing_secret"), asRecord(error.details) ?? {}); + throw error; } - if (!grant.subjectUserId) { - throw new ToolGatewayHttpError( - 422, - "Personal authorization has no owner", - "grant_owner_missing", - { - connectionId: connection.id, - grantId: grant.id, - }, - ); - } - const [secret] = await db - .select({ - scope: companySecrets.scope, - ownerUserId: companySecrets.ownerUserId, - userSecretDefinitionId: companySecrets.userSecretDefinitionId, - }) - .from(companySecrets) - .where( - and( - eq(companySecrets.id, ref.secretId), - eq(companySecrets.companyId, connection.companyId), - ), - ) - .limit(1); - if ( - !secret || - secret.scope !== "user" || - secret.ownerUserId !== grant.subjectUserId || - !secret.userSecretDefinitionId - ) { - throw new ToolGatewayHttpError( - 422, - "Personal authorization has an invalid credential", - "grant_credential_invalid", - { - connectionId: connection.id, - grantId: grant.id, - credential: configPath, - }, - ); - } - const resolved = await secrets.resolveUserSecretValue( - connection.companyId, - { - definitionId: secret.userSecretDefinitionId, - responsibleUserId: grant.subjectUserId, - version: ref.versionSelector ?? "latest", - required: ref.required ?? true, - }, - accessContext, - ); - if (!resolved) { - throw new ToolGatewayHttpError( - 422, - "Personal credential is not configured", - "user_secret_missing", - { - connectionId: connection.id, - grantId: grant.id, - credential: configPath, - }, - ); - } - return resolved.value; } async function maybeRefreshPaperclipCloudGrant( @@ -4105,20 +4029,15 @@ export function createToolGatewayService( connection, grant, grantRef, - // Personal grants resolve against their declared vault path. API-key - // paths already include credentials.* or headers.*; prepending again - // breaks the declaration just as it does for OAuth token paths. - grant.kind === "user" || grantRef.configPath.startsWith("oauth.") - ? grantRef.configPath - : `credentials.${ref.name}`, ); headers[ref.key] = `${ref.prefix ?? ""}${value}`; - } catch { + } catch (error) { await markRemoteConnectionHealth( connection, "missing_secret", "A configured credential secret could not be resolved.", ); + if (error instanceof ToolGatewayHttpError && error.reasonCode === "grant_credential_invalid") throw error; throw new ToolGatewayHttpError( 422, "A configured credential secret could not be resolved.", @@ -4139,12 +4058,13 @@ export function createToolGatewayService( oauthAccessRef, ); headers.Authorization = `Bearer ${value}`; - } catch { + } catch (error) { await markRemoteConnectionHealth( connection, "missing_secret", "A configured credential secret could not be resolved.", ); + if (error instanceof ToolGatewayHttpError && error.reasonCode === "grant_credential_invalid") throw error; throw new ToolGatewayHttpError( 422, "A configured credential secret could not be resolved.", @@ -4226,7 +4146,7 @@ export function createToolGatewayService( const configPath = typedRef.placement === "url" ? REMOTE_URL_SECRET_CONFIG_PATH - : `credentials.${typedRef.name}`; + : grantRef.configPath; headerCredentialVersions.push( await resolveConnectedCredentialVersion(connection, { secretId: grantRef.secretId, @@ -5987,6 +5907,11 @@ export function createToolGatewayService( }), }; let response = await dispatchRemote(endpoint, requestInit); + if (isInsufficientConnectionScope(response)) { + await response.body?.cancel().catch(() => undefined); + await markRemoteConnectionHealth(connection, "degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE); + throw new ToolGatewayHttpError(403, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, "oauth_insufficient_scope", { connectionId: connection.id }); + } const oauth = asRecord(asRecord(connection.config)?.oauth); if ( response.status === 401 && @@ -6128,6 +6053,12 @@ export function createToolGatewayService( response.headers.get("x-zapier-request-id") ?? response.headers.get("traceparent"), }; + if (isInsufficientConnectionScope(response, body)) { + await markRemoteConnectionHealth(connection, "degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE); + throw new ToolGatewayHttpError(403, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, "oauth_insufficient_scope", { + connectionId: connection.id, catalogEntryId: entry.id, + }); + } if (!response.ok) { // Session expiration is recoverable on an explicit retry. Marking the // connection unhealthy here would hide every tool and prevent it. diff --git a/tests/e2e/apps-prosumer-mcp-flow.spec.ts b/tests/e2e/apps-prosumer-mcp-flow.spec.ts index c2bdb8b5bb..7b18344b2d 100644 --- a/tests/e2e/apps-prosumer-mcp-flow.spec.ts +++ b/tests/e2e/apps-prosumer-mcp-flow.spec.ts @@ -183,9 +183,10 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => { // Verify the mock saw a tools/list call from the catalog refresh. expect(mock.captures.some((c) => c.method === "tools/list")).toBe(true); - // The new connection should show up on /apps/connections. - await gotoApps(page, seed.prefix); + // Return through the UI so a page reload cannot hide a stale catalog cache. + await page.getByRole("button", { name: "View connection" }).click(); await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("heading", { name: `${new URL(mock.url).host} for the organization`, exact: true })).toBeVisible(); await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-06-apps-list.png`, fullPage: true }); }); @@ -243,10 +244,12 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => { }); expect(repatch.ok(), `patch url failed ${repatch.status()}: ${await repatch.text()}`).toBe(true); - const reconnect = await request.post(`/api/tool-connections/${connectionId}/reconnect`, { - data: { credentialValues: { "credentials.authorization": "fresh-key" } }, - }); - expect(reconnect.ok(), `reconnect failed ${reconnect.status()}: ${await reconnect.text()}`).toBe(true); + await page.reload(); + await page.getByLabel("App key", { exact: true }).fill("fresh-key"); + await page.getByRole("button", { name: "Check & reconnect", exact: true }).click(); + await expect(page.getByText("Reconnected", { exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "This app needs reconnecting" })).toHaveCount(0); + await expect(page.getByText("Still not working", { exact: true })).toHaveCount(0); const after = await request.get(`/api/tool-connections/${connectionId}`); const afterBody = await after.json(); diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index e190796387..2864c7f8cd 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -3,7 +3,7 @@ import { useMemoryConnectorsEnabled } from "@/hooks/useMemoryConnectorsEnabled"; import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep"; import { ConnectionChoiceList } from "./ConnectionChoiceList"; import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode, type Ref } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowUpRight, Bot, @@ -583,6 +583,7 @@ function StandardConnectionSetupFlow({ onCancel, renderCredentialStep, }: ConnectionSetupFlowProps = {}) { + const queryClient = useQueryClient(); const routeNavigate = useNavigate(); const navigate = useCallback((to: string, options?: { replace?: boolean }) => { if (host !== "page") return; @@ -1717,7 +1718,9 @@ function StandardConnectionSetupFlow({ await applyAccessInstalls(connected.connectionId); return finished; }, - onSuccess: (_finished, input) => { + onSuccess: async (_finished, input) => { + await queryClient.invalidateQueries({ queryKey: ["tools"] }); + await queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) }); setAppStep("success"); onComplete?.({ connectionId: input.result.connectionId }); }, @@ -3143,6 +3146,9 @@ function LinkConnectStep({ placeholder="••••••••••••••••" className="mt-2 h-11 font-mono" /> +

+ Grant this key read and write access to the resources your agents need. Paperclip cannot increase its permissions. +

) : null} @@ -3470,7 +3476,8 @@ function KeyStep({ const optionalCustomerOAuthClient = !usingVercel && acceptsCustomerOAuthClient && !customerOAuthClientRequired; - const hasAdvancedSettings = advancedConfigFields.length > 0 || optionalCustomerOAuthClient; + const hasReadOnlyAlternatives = capabilityGroups.length > 1 && capabilityGroups.some((group) => group.key === "read"); + const hasAdvancedSettings = advancedConfigFields.length > 0 || optionalCustomerOAuthClient || hasReadOnlyAlternatives; const capabilitySelection = capabilityGroups.length > 1 ? (
@@ -3614,7 +3621,12 @@ function KeyStep({ ) : null}
- {capabilitySelection} + {!hasReadOnlyAlternatives && capabilitySelection} + {(hasReadOnlyAlternatives || capabilityGroups.length === 1) && method?.capabilityProfile && ( +

+ {method.capabilityProfile.label}: {method.capabilityProfile.description} +

+ )} {authenticationSelection} {usingVercel && vercelReview && vercelConnectAvailability ? ( @@ -3672,6 +3684,7 @@ function KeyStep({
+ {hasReadOnlyAlternatives && capabilitySelection} {advancedConfigFields.map((field) => ( +

+ {field.helperMd ?? "Create a key with read and write permissions for the resources your agents need. Paperclip cannot add permissions to an existing key."} +

{field.helpUrl && ( void; upstreamServiceName?: string; host?: "page" | "dialog"; lockedAgentId?: string; @@ -63,7 +64,7 @@ export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agent = 0 && !s.setupComplete ? `Step ${currentStep + 1} of 2` : s.step === "draft" ? `Your ${provider.name} setup is ready to resume.` : s.step === "permissions" ? `Connected${s.identity ? ` as ${s.identity}` : ""} · ${s.tools.length} actions available` : `Manage this ${provider.name} connection.`} - step={currentStep >= 0 && !s.setupComplete ? "access" : "gallery"} activeIndex={currentStep} labels={["Access", "Connect"]} onCancel={busy || s.step === "management" || s.step === "permissions" || s.step === "draft" ? undefined : a.saveExit} /> + step={currentStep >= 0 && !s.setupComplete ? "access" : "gallery"} activeIndex={currentStep} labels={["Access", "Connect"]} onCancel={busy || s.step === "management" || s.step === "permissions" || s.step === "draft" ? undefined : onCancel ?? a.saveExit} />
{upstreamServiceName && {provider.name} is an external service that handles the connection and requests to {upstreamServiceName}. After connecting, the agent will verify the app and guide you through any additional authorization.} {s.notice &&

{s.notice}

} diff --git a/ui/src/features/connections/remote-mcp/RemoteMcpProductionSetup.tsx b/ui/src/features/connections/remote-mcp/RemoteMcpProductionSetup.tsx index 42966566a7..a78ffb29da 100644 --- a/ui/src/features/connections/remote-mcp/RemoteMcpProductionSetup.tsx +++ b/ui/src/features/connections/remote-mcp/RemoteMcpProductionSetup.tsx @@ -187,5 +187,7 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page" void onUseExisting(id).catch((error) => { setChoiceError(error instanceof Error ? error.message : "Could not use this connection."); setChoicePending(null); }); }} />; if (connection && !installs.data) return

{installs.isError ? "Could not load saved access. Retry before changing this connection." : "Loading saved access…"}

{installs.isError && }
; - return ; + // Header Cancel abandons unsaved input, including invalid URLs. The separate + // Save & exit action persists a resumable draft through actions.saveExit. + return navigate("/apps"))} upstreamServiceName={upstreamServiceName} host={host} lockedAgentId={requestedAgentId} authorizationUrl={host === "dialog" ? authorizationUrl.current : undefined} provider={provider} connectionId={savedConnection.current?.id ?? ""} fixedGrantKind={savedConnection.current ? savedConnection.current.credentialPolicy === "per_user" ? "user" : "organization" : undefined} state={state} actions={actions} agents={agents.data ?? []} />; } diff --git a/ui/src/pages/apps/AppDetail.test.tsx b/ui/src/pages/apps/AppDetail.test.tsx index 8bf0869365..767c24eed3 100644 --- a/ui/src/pages/apps/AppDetail.test.tsx +++ b/ui/src/pages/apps/AppDetail.test.tsx @@ -29,6 +29,7 @@ const putConnectionInstallsMock = vi.hoisted(() => vi.fn()); const refreshCatalogMock = vi.hoisted(() => vi.fn()); const checkConnectionHealthMock = vi.hoisted(() => vi.fn()); const startOAuthMock = vi.hoisted(() => vi.fn()); +const reconnectConnectionMock = vi.hoisted(() => vi.fn()); const listConnectionGrantsMock = vi.hoisted(() => vi.fn()); const revokeConnectionGrantMock = vi.hoisted(() => vi.fn()); const createConnectionGrantDelegationMock = vi.hoisted(() => vi.fn()); @@ -88,7 +89,7 @@ vi.mock("@/api/tools", () => ({ replaceConnectionGrantMembersMock(connectionId, grantId, memberUserIds), startPersonalAuthorization: (companyId: string, connectionId: string, input: unknown) => startPersonalAuthorizationMock(companyId, connectionId, input), - reconnectConnection: vi.fn(), + reconnectConnection: (id: string, values: unknown) => reconnectConnectionMock(id, values), }, })); @@ -1207,6 +1208,31 @@ describe("AppDetail", () => { expect(container.textContent).toContain("Which agents can use this connection?"); }); + it.each([ + { name: "remote.url", placement: "url", key: "url", prefix: null, label: "MCP server URL", value: "https://example.com/mcp?token=fresh", path: "remote.url" }, + { name: "headers.X-Api-Key", placement: "header", key: "X-Api-Key", prefix: null, label: "X-Api-Key", value: "fresh-key", path: "headers.X-Api-Key" }, + { name: "authorization", placement: "header", key: "Authorization", prefix: "Bearer ", label: "App key", value: "fresh-token", path: "credentials.authorization" }, + ])("reconnects a generic $label using its stored credential placement", async (fixture) => { + listGalleryMock.mockResolvedValue({ apps: [] }); + listApplicationsMock.mockResolvedValue({ applications: [] }); + getConnectionMock.mockResolvedValue(connection({ + authKind: "api_key", + healthStatus: "missing_secret", + credentialRefs: [{ ...fixture, secretId: "old-secret", version: "latest" }], + })); + reconnectConnectionMock.mockResolvedValue({ connection: connection({ healthStatus: "ok" }) }); + await renderAppDetail(); + + const field = container.querySelector(`input[aria-label="${fixture.label}"]`); + expect(field).not.toBeNull(); + await act(() => setInputValue(field!, fixture.value)); + await act(() => findButton("Check & reconnect")!.click()); + await flushReact(); + + expect(reconnectConnectionMock).toHaveBeenCalledWith("conn-1", { [fixture.path]: fixture.value }); + expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ title: "Reconnected" })); + }); + it.each(["permissions", "review"])("offers a supported replacement for an obsolete Anthropic connection on %s", async (tab) => { mockParams.tab = tab; listApplicationsMock.mockResolvedValue({ applications: [] }); diff --git a/ui/src/pages/apps/AppDetail.tsx b/ui/src/pages/apps/AppDetail.tsx index 15822a874f..743080ddd2 100644 --- a/ui/src/pages/apps/AppDetail.tsx +++ b/ui/src/pages/apps/AppDetail.tsx @@ -657,6 +657,18 @@ export function AppDetail({ renderActions, onReconnect }: { replaceAudience.mutate({ grantId: grant.id, memberUserIds })} /> {isRemoteMcpConnectorMethod(connection.config?.sourceTemplateKey, connection.config?.connectionMethodKey) &&

Paperclip controls access to the tools listed here. App and action permissions inside these tools are managed in {baseAppName}.

} + {connection.authKind === "oauth" && ( +
+

+ Provider permissions come from your last sign-in. Reconnect to grant missing write access, then enable the actions you need here. +

+ {canReconnect && } +
+ )} { expect(container.textContent).toContain("Step 2 of 2"); }); + it.each(["page", "dialog"] as const)("lets %s setup cancel after an invalid provider URL without trying to save it", async (host) => { + const onCancel = host === "dialog" ? vi.fn() : undefined; + connectAppMock.mockRejectedValue(new Error("That connection URL does not belong to Zapier")); + await render(undefined, false, ); + await passAccessStep(); + await act(async () => setInputValue(container.querySelector('input[type="password"]')!, "https://wrong-provider.example/mcp")); + await act(async () => buttonByText("Connect")!.click()); + await vi.waitFor(() => expect(container.textContent).toContain("That connection URL does not belong to Zapier")); + + await act(async () => buttonByText("Cancel")!.click()); + + expect(connectAppMock).toHaveBeenCalledTimes(1); + if (onCancel) expect(onCancel).toHaveBeenCalledOnce(); + else expect(mockNavigate).toHaveBeenCalledWith("/apps"); + }); + it("inline aggregator reuses an eligible account without changing its access", async () => { const onUseExisting = vi.fn().mockResolvedValue(undefined); await render(undefined, false, ); @@ -1103,7 +1119,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { await flushReact(); await flushReact(); if (!popupBlocked) expect(popup.close).toHaveBeenCalled(); - expect(container.textContent).toContain("What should Paperclip be able to do?"); + expect(container.textContent).not.toContain("What should Paperclip be able to do?"); + expect(buttonByText("Advanced")).toBeDefined(); expect(container.textContent).toContain("Step 2 of 2"); connectAppMock.mockResolvedValue({ connectionId: "gmail-1", connection: { id: "gmail-1", credentialPolicy: "per_user" }, auth: { kind: "oauth", startUrl: "https://example.test/unbound" } }); await act(async () => buttonByText("Continue to sign in")?.click()); @@ -1465,6 +1482,9 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { } if (definition.methods.some((method) => method.capabilityProfile?.key !== "read")) { + expect(radioContaining(readMethod.capabilityProfile!.label)).toBeUndefined(); + await act(async () => { buttonByText("Advanced")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + await flushReact(); const readChoice = radioContaining(readMethod.capabilityProfile!.label); expect(readChoice).not.toBeNull(); await act(async () => { @@ -1537,6 +1557,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(radioContaining("Any human in the organization")?.getAttribute("aria-checked")).toBe("true"); await passAccessStep(); + expect(container.textContent).toContain("Read & create"); + expect(radioContaining("Read only")).toBeUndefined(); + await act(async () => { buttonByText("Advanced")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + await flushReact(); expect(radioContaining("Read & create")?.getAttribute("aria-checked")).toBe("true"); expect(radioContaining("Read only")?.getAttribute("aria-checked")).toBe("false"); expect(container.textContent).not.toContain("Before connecting, enroll the signed-in Workspace account"); @@ -1555,7 +1579,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { (heading) => heading.textContent?.trim() === "Connect Google Calendar", ); expect(duplicateHeadings).toHaveLength(1); - expect(container.textContent).toContain("What should Paperclip be able to do?"); + expect(container.textContent).not.toContain("What should Paperclip be able to do?"); + expect(buttonByText("Advanced")).toBeDefined(); expect(container.textContent).toContain("Review requirements"); expect(container.textContent).not.toContain("Connect Google Calendar to read and manage events."); expect(container.textContent).not.toContain("All event mutations require approval."); @@ -1565,6 +1590,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(container.textContent).not.toContain("You’ll sign in before anything turns on."); expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); + await act(async () => { buttonByText("Advanced")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + await flushReact(); const capabilityQuestion = Array.from(container.querySelectorAll("label")).find( (label) => label.textContent === "What should Paperclip be able to do?", ); @@ -2986,6 +3013,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); await flushReact(); await passAccessStep(); + await act(async () => { buttonByText("Advanced")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + await flushReact(); await act(async () => { buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); @@ -3012,6 +3041,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); await flushReact(); await passAccessStep(); + await act(async () => { buttonByText("Advanced")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + await flushReact(); await act(async () => { buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); @@ -3187,6 +3218,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); await flushReact(); await passAccessStep(); + await act(async () => { buttonByText("Advanced")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + await flushReact(); await act(async () => { buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); @@ -3225,6 +3258,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); await flushReact(); await passAccessStep(); + await act(async () => { buttonByText("Advanced")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + await flushReact(); await act(async () => { buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); diff --git a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx index cd1291c2ba..2d8b7f66b8 100644 --- a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx +++ b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx @@ -7,7 +7,7 @@ import type { ToolConnection, ToolConnectionCredentialPolicy, } from "@paperclipai/shared"; -import { credentialConfigPath, getAvailableConnectionMethod, humanizeConnectionDisplayName } from "@paperclipai/shared"; +import { connectionCredentialConfigPath, credentialConfigPath, getAvailableConnectionMethod, humanizeConnectionDisplayName } from "@paperclipai/shared"; import { toolsApi } from "@/api/tools"; import { Button } from "@/components/ui/button"; import { @@ -210,7 +210,7 @@ export function ReconnectCard({ const methodUnavailable = connectionMethodUnavailable(connection, galleryEntry); return ( -
+

{methodUnavailable ? "Connection no longer supported" : oauth ? "Reconnect required" : "This app needs reconnecting"} @@ -289,25 +289,33 @@ function ReconnectForm({ const method = galleryEntry && Array.isArray(galleryEntry.methods) ? getAvailableConnectionMethod(galleryEntry, methodKey) : null; - const fields = (method?.credentialFields ?? []).map((field) => ({ + const galleryFields = (method?.credentialFields ?? []).map((field) => ({ ...field, configPath: credentialConfigPath(field, method), helpUrl: method?.consoleLinks?.keys ?? method?.consoleLinks?.docs ?? "", })); + const fields = galleryFields.length > 0 ? galleryFields : (connection.credentialRefs ?? []) + .filter((ref) => ref.placement === "header" || ref.placement === "url") + .map((ref) => ({ + configPath: connectionCredentialConfigPath(ref), + label: ref.placement === "url" ? "MCP server URL" : ref.prefix === "Bearer " ? "App key" : ref.key ?? ref.name, + helpUrl: "", + required: true, + })); const [values, setValues] = useState>({}); const [single, setSingle] = useState(""); - const usesGallery = fields.length > 0 && !!galleryEntry; + const usesFields = fields.length > 0; const reconnect = useMutation({ mutationFn: () => { - const credentialValues = usesGallery + const credentialValues = usesFields ? values : { "credentials.authorization": single.trim() }; return toolsApi.reconnectConnection(connection.id, credentialValues); }, onSuccess: (result) => { const healthy = - result.connection.healthStatus === "healthy" || result.connection.healthStatus === "unknown"; + result.connection.healthStatus === "ok" || result.connection.healthStatus === "healthy" || result.connection.healthStatus === "unknown"; if (healthy) { pushToast({ title: "Reconnected", @@ -331,7 +339,7 @@ function ReconnectForm({ }), }); - const filled = usesGallery + const filled = usesFields ? fields.every((f) => f.required === false || (values[f.configPath]?.trim().length ?? 0) > 0) : single.trim().length > 0; @@ -345,12 +353,13 @@ function ReconnectForm({ return (
- {usesGallery ? ( + {usesFields ? ( fields.map((field) => (
setValues({ ...values, [field.configPath]: e.target.value })} @@ -372,6 +381,7 @@ function ReconnectForm({ ) : ( setSingle(e.target.value)} diff --git a/ui/src/pages/apps/app-detail/TestPanel.test.tsx b/ui/src/pages/apps/app-detail/TestPanel.test.tsx index 99f6e0d896..3b14ea5c5a 100644 --- a/ui/src/pages/apps/app-detail/TestPanel.test.tsx +++ b/ui/src/pages/apps/app-detail/TestPanel.test.tsx @@ -324,6 +324,29 @@ describe("TestPanel", () => { expect(container.textContent).toContain("Preview"); }); + it.each(["grant_credential_invalid", "oauth_insufficient_scope"])("guides %s failures to reconnect instead of changing action inputs", async (reasonCode) => { + runTestCallMock.mockResolvedValue({ + decision: "allowed", + invocationId: "inv-credential", + error: { message: "The connection must be reconnected.", reasonCode }, + }); + await act(async () => renderPanel()); + await flushReact(); + const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet")); + await act(async () => trigger!.click()); + await flushReact(); + await fillFormField("sheet-123"); + await clickByText("Run"); + await settle(); + + expect(container.textContent).toContain("After reconnecting, run this action again."); + expect(container.textContent).not.toContain("Adjust the input above"); + expect(container.textContent).not.toContain("Check the field formats"); + expect(container.textContent).toContain(reasonCode === "grant_credential_invalid" + ? "Ask the connection owner to reconnect" + : "allow the permissions required for this action"); + }); + it("renders a failure card (not 'Worked') when an allowed call returns isError:true", async () => { // The gateway let the call through (decision:"allowed") but the upstream MCP // tool failed at the tool layer — the envelope carries isError + an error. diff --git a/ui/src/pages/apps/app-detail/TestPanel.tsx b/ui/src/pages/apps/app-detail/TestPanel.tsx index ada08f1919..ed3557fd8b 100644 --- a/ui/src/pages/apps/app-detail/TestPanel.tsx +++ b/ui/src/pages/apps/app-detail/TestPanel.tsx @@ -1360,6 +1360,7 @@ function ErrorResult({ error: { message: string; reasonCode: string | null }; }) { const hints = errorHints(error.message, error.reasonCode); + const needsReconnect = isReconnectError(error.reasonCode); return (
@@ -1383,7 +1384,9 @@ function ErrorResult({ ))}
-

Adjust the input above and try again.

+

+ {needsReconnect ? "After reconnecting, run this action again." : "Adjust the input above and try again."} +

Also visible in the{" "} @@ -1653,7 +1656,17 @@ function safeStringify(value: unknown): string { * board-accepted copy-spec error-hint lookup (NOT_FOUND / PERMISSION_DENIED / * INVALID_ARGUMENT / RATE_LIMIT) with the locked generic fallback otherwise. */ +function isReconnectError(reasonCode: string | null | undefined): boolean { + return reasonCode === "grant_credential_invalid" || reasonCode === "oauth_insufficient_scope"; +} + export function errorHints(message: string, reasonCode: string | null | undefined): string[] { + if (reasonCode === "grant_credential_invalid") { + return ["Ask the connection owner to reconnect it from Connectors with a fresh key or server URL."]; + } + if (reasonCode === "oauth_insufficient_scope") { + return ["Reconnect the app and allow the permissions required for this action."]; + } const haystack = `${reasonCode ?? ""} ${message}`.toUpperCase(); if (haystack.includes("NOT_FOUND")) { return [