Preserve current CI master fixes in the final Copilot candidate

Refresh only the unqualified profile 36 identity after the required merge. Retain upstream OpenCode dependency resolution and the CI-owned lockfile.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-09 12:14:38 -05:00
commit 9769279bb7
47 files changed
+1771 -238

No files matched your search

+9
View File
@@ -548,6 +548,15 @@ Activity-only reorderings wait for one second without further activity changes;
new and removed tasks appear immediately. Titles, status, and live indicators stay
current during that delay.
### OpenCode native runtime
Paperclip Runner resolves the pinned OpenCode platform package installed with
Paperclip. On Apple Silicon and Intel Macs it runs the matching macOS binary;
on Linux x64 it runs the qualified baseline binary. No global OpenCode command
or dependency postinstall script is required. This also works when installation
scripts are disabled. The published server declares the same pinned dependency
as the source Runner package.
## One-Command Local Run
For a first-time local install, you can bootstrap and run in one command:
@@ -0,0 +1,67 @@
# Hosted MCP qualification — 2026-10-09
This batch continues the verified connector work in [PR #15621](https://github.com/paperclipai/paperclip/pull/15621). Provider research was checked against the existing paperclip-content integration research and the canonical [connector playbook](CONNECTOR-PLAYBOOK.md). Luna workers researched and exercised separate providers in an isolated local Paperclip company.
## Completed browser journeys
| Provider / method | Setup | Discovered actions | Bounded UI Test | Persistence |
| --- | --- | --- | --- | --- |
| Bitly / OAuth | New account and `mcp.all` explicitly approved; HTTPS DCR/OAuth | 56: 30 read, 26 write | `bitly_get_user`, 2.0s; returned profile values omitted | Reload and catalog refresh retained 56 actions, Just me, zero selected agents, all writes Off |
| Context7 / public MCP | No account, key, or OAuth | 2 read, 0 write | Resolve React → `/reactjs/react.dev`, 2.9s; useEffect cleanup documentation query, 3.0s | Repeat query after reload passed in 3.1s; zero selected agents retained |
| Hugging Face / public MCP | No account, key, or OAuth; existing OAuth method retained separately | 4 read, 0 write | `hf_fs` public documentation search, 0.7s | Exact same query passed after reload in 0.8s; 4 actions and zero selected agents retained |
| Resend / OAuth | Team grant `full_access` explicitly approved; HTTPS DCR/OAuth | 132: 55 read, 77 write | `List Domains` at 4.3s and 0.9s; final fresh setup after the access-marker refinement passed in 1.4s; one entry each, values omitted | Fresh setup/reload retained Just me and zero agents; all actions Off in final reloaded state |
Tests used the actual Apps setup and Permissions Test dialog. The built-in Connector Tester was the test actor; no real agent adapter, task, or runtime was started. Discovery and read proof do not establish write behavior, token-refresh rotation, or every advertised action.
Bitly's older recovery connection was left with all 56 actions Off. Resend's three older qualification connections were also left all Off. Provider-wide OAuth grants still exist; local action policies govern execution separately. No email was sent, link created, destructive provider action executed, purchase made, or paid job started.
For Resend, an earlier bulk-Off change was not reflected after a reload. Its historical cause is unresolved. A controlled Off → Ask first → reload → Off → reload check, waiting for the visible Saving indicator to clear before each reload, preserved both settled values. No policy persistence defect was reproduced. Qualification records the final persisted state, not an assumed explanation for that earlier observation.
## Incomplete providers
These definitions and durable overrides are retained for further work, but withheld as new Browse candidates. Existing installed/incomplete connections remain visible for recovery. They are not claimed as live-qualified.
| Provider | Observed gate | Next concrete step |
| --- | --- | --- |
| monday.com | Registration HTTP 400: `redirect_uris are not allowed: please contact monday to add the redirect URIs to the allowlist` | Obtain provider approval for the HTTPS callback. Account-admin approval may also apply after registration. No grant or account read occurred. |
| ClickUp | Both retained and fresh Google identity flows returned to blank ClickUp login with `Password required` | Resolve the ClickUp account/login route, then review its `read write` provider consent and run a bounded workspace read. No grant or authenticated catalog occurred. |
| Klaviyo | Initial client authorization reached; no account or exact scope strings enumerated; optional prompt-intent collection unchecked | Await the separate client authorization decision, then review any subsequent account/scope grant. No approval, account read, campaign send, or deletion occurred. |
The saved monday.com draft was resumed from Browse and survived reload in the actual app after the recovery fix. Fresh `source=monday` setup stayed withheld, and using the same draft with `source=clickup` showed a provider-mismatch error. These checks did not retry authorization or approve a provider grant. An extra public Context7 connection was seeded as a saved draft through the local API solely to exercise recovery. Browse → Finish setup → Connect reached the success screen after activation, with zero agents selected; the saved Connected state and zero-agent choice survived a Permissions reload. Refreshing the generated `stage=complete` URL now opens that exact saved Permissions page after validating active status, company, application, and provider. View connection also opens that exact page. A normal active recovery URL and a completed URL with a mismatched provider stayed blocked in the browser; a draft at the completion stage does not imply success.
monday.com is recorded as provider-approval blocked in the research ledger. ClickUp and Klaviyo retain their documented DCR setup research but remain hidden pending actual qualification.
## Shared fixes found during real use
- Token exchange now requests `Accept: application/json`. Bitly documents a legacy form response when this header is absent. The first callback reported `oauth_access_token_missing`; a fresh equivalent authorization succeeded after the header fix. The earlier token response body/content type was not captured, so the exact old payload remains unproven. Code exchange and refresh use the same exchange function.
- Remote MCP callbacks preserve an explicitly saved Access step, including zero selected agents. Saving installs now records a transactional marker, distinguishing a deliberate empty selection from a flow that has not selected access. The old branch only recognized aggregator MCP methods, causing fresh Resend setup to replace an empty install set with access for all agents. Empty, individual-agent, company, and unconfigured-default selections have regression coverage for install rows and profile bindings. Managed OAuth keeps its subject/recommended defaults when no Access choice was saved.
- Saved hidden-provider drafts now reach their exact setup wizard from Finish setup. The wizard validates the company-visible draft, its application, and matching provider before resolving a hidden definition; unknown or mismatched drafts stay on an error screen. Fresh hidden-provider setup remains withheld. Completion now accepts the exact active row only after this flow successfully finishes it, so the draft → active refetch preserves the success screen; normal initially active, disabled, archived, and mismatched recovery URLs remain rejected.
Provider-specific changes use durable overrides so ingestion preserves them. Bitly and Resend use full documented account capabilities; Context7 and Hugging Face add or qualify public read endpoints. No custom skills or channels are required for these basic MCP tool connections.
## Evidence and sources
Evidence is recorded as text only. Account-profile and domain results are omitted; no evidence images are included. Provider-specific `liveProof` and `evidenceLimit` records are in [tool-method-permission-reviews.json](tool-method-permission-reviews.json); the dated research entries are in [self-serve-mcp-research.json](../../packages/shared/src/self-serve-mcp-research.json).
- [Bitly hosted MCP quickstart](https://dev.bitly.com/bitly-mcp/overview/quickstart/) and [token response format](https://dev.bitly.com/docs/getting-started/authentication/).
- [Context7 API guide](https://context7.com/docs/api-guide) and [official MCP source](https://github.com/upstash/context7/blob/master/packages/mcp/src/index.ts).
- [Resend remote MCP](https://resend.com/changelog/remote-mcp-server).
- [monday.com MCP integration](https://developer.monday.com/api-reference/docs/integrate-with-monday-mcp).
- [ClickUp MCP](https://developer.clickup.com/docs/connect-an-ai-assistant-to-clickups-mcp-server).
- [Klaviyo MCP](https://developers.klaviyo.com/en/docs/connect_to_the_klaviyo_mcp_server).
- [Hugging Face MCP](https://huggingface.co/docs/hub/agents-mcp) and [official server](https://github.com/huggingface/hf-mcp-server).
## Validation
- Shared catalog suite: 36 passed.
- Durable override suite: 3 passed.
- Focused UI setup, recovery, Browse, and OAuth-helper suites: 306 passed.
- Targeted mock MCP Playwright journey: 1 passed in 34.9s on an isolated harness. It checks the exact saved Permissions route, both advertised actions, and reload persistence. CI caught its stale Browse expectation after View connection changed; the test now covers the intended destination.
- Brand validation: 101 identities passed.
- Full connector service suite: 420 passed, including the four-case JSON-token/access regression and existing managed OAuth lifecycle coverage.
- Recursive typecheck: passed again after rebasing onto current master.
- Recursive production build: passed again after rebasing onto current master. UI typecheck, production build, and token gates passed again after the draft-recovery fix.
- Full local repository Vitest was started before the rebase and cancelled as superseded while still in the general-server phase. It is not counted as a completed pass. Broad current-head verification is tracked in [PR #15686 checks](https://github.com/paperclipai/paperclip/pull/15686/checks); the complete connector service suite passed separately against the final production code.
The local host reached its macOS System V shared-memory limit during a service restart. Only stale, unattached user-owned segments with dead creator/operator processes were reclaimed; other running apps and their databases were left intact. This host resource failure is separate from provider login/approval gates.
@@ -0,0 +1,38 @@
{
"reviewedAt": "2026-10-09",
"providers": [
{
"slug": "monday",
"provider": "monday.com",
"localAsset": "/brands/apps/monday.png",
"sourceUrl": "https://monday.com/p/wp-content/uploads/2024/03/monday.com_-1.png",
"sourceContext": "https://monday.com/p/news/press-kit/ (official press kit; complete monday.com wordmark)",
"sha256": "08d1592465b32f1cc6b6d2efd2da64f22496d4d3a887f6506e083dceceb390e2",
"darkAsset": "/brands/apps/monday-dark.png",
"darkSourceUrl": "https://monday.com/p/wp-content/uploads/2024/03/White-logo.png",
"darkSha256": "a5e8e4984f7a79c5e9d7de8b94d6c229d98c54ed2863eca6d2a080ad17d8510a",
"retrievedAt": "2026-10-09",
"note": "Unmodified official press-kit full wordmark variants. The provider's press-kit guidance asks for the complete monday.com text and the colored mark; white wordmark variant is used on dark surfaces."
},
{
"slug": "clickup",
"provider": "ClickUp",
"file": "ui/public/brands/apps/clickup.png",
"sourceUrl": "https://clickup.com/favicons/apple-touch-icon.png",
"linkedFrom": "https://clickup.com/",
"retrievedAt": "2026-10-09",
"sha256": "fb45023441f0794d1ece6ef59542d360c8bf4f4c3bde2c84c4d546ae8019ed72",
"note": "Official ClickUp touch icon, retained without modification."
},
{
"slug": "klaviyo",
"provider": "Klaviyo",
"file": "ui/public/brands/apps/klaviyo.png",
"sourceUrl": "https://www.klaviyo.com/icons/icon-144x144.png",
"linkedFrom": "https://www.klaviyo.com/",
"retrievedAt": "2026-10-09",
"sha256": "61a5bd241fb300f51f6bce5c71887e3795e91c64ca9e62d676e0ed9eae6e8796",
"note": "Official Klaviyo site icon; retained at native 144x144 pixels without modification."
}
]
}
@@ -40,7 +40,7 @@
],
"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’s permissions.",
"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."
},
{
@@ -113,18 +113,22 @@
"app": "bitly",
"method": "mcp-oauth",
"auth": "oauth",
"policy": "provider-default",
"requestedScopes": [],
"capability": "provider-controlled",
"supportedActions": "Create/manage links in authorized groups.",
"policy": "explicit",
"requestedScopes": [
"mcp.all"
],
"capability": "write",
"supportedActions": "Read user, organizations, groups, links, QR codes, analytics, domains, and Bitly Sites; create/update links and QR codes, alter group/domain/site settings, create or edit site content, and publish; permanently delete short links, Sites, site blocks, and redirects. Domain-registration tools may create a complimentary domain after accepting provider/registrar agreements. Actions remain subject to the authorized Bitly account and group permissions.",
"evidence": [
"https://dev.bitly.com/bitly-mcp/overview/quickstart/",
"https://dev.bitly.com/bitly-mcp/using-the-bitly-mcp-server/configuration-details/",
"https://dev.bitly.com/bitly-mcp/using-the-bitly-mcp-server/mcp-tools-reference/",
"https://dev.bitly.com/bitly-mcp/overview/mcp-changelog/",
"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."
"reviewedAt": "2026-10-09",
"liveProof": "HTTPS DCR/OAuth with approved mcp.all completed after JSON token response negotiation. Discovered 56 actions (30 read, 26 write). bitly_get_user passed through the Paperclip Test dialog in 2.0s; account values omitted. Reload/catalog refresh retained actions, zero selected agents, and all 26 writes Off. Older recovery connection left all 56 actions Off.",
"evidenceLimit": "Direct UI Test using the Connector Tester actor, not an agent adapter run. No links created, provider writes, paid actions, token refresh or API-key method tested."
},
{
"app": "bitly",
@@ -132,16 +136,16 @@
"auth": "api_key",
"policy": "provider-key",
"requestedScopes": [],
"capability": "provider-controlled",
"supportedActions": "Create/manage links in authorized groups.",
"capability": "write",
"supportedActions": "The same broad provider MCP catalog as the OAuth method, under the account associated with the API token: link and QR management, analytics, groups, custom-domain operations, and Bitly Sites, including publishing and deletion.",
"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"
"https://dev.bitly.com/bitly-mcp/using-the-bitly-mcp-server/configuration-details/",
"https://dev.bitly.com/bitly-mcp/using-the-bitly-mcp-server/mcp-tools-reference/"
],
"reviewedAt": "2026-09-30",
"reviewedAt": "2026-10-09",
"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’s permissions."
"keyPermissions": "A Bitly API token from Settings \u2192 API is documented for MCP bearer auth, but no restriction scope is documented; treat it as capable of the full MCP catalog under that account. Paperclip cannot narrow the token."
},
{
"app": "box",
@@ -209,7 +213,7 @@
],
"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’s permissions."
"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",
@@ -283,7 +287,7 @@
],
"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’s permissions."
"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",
@@ -339,7 +343,7 @@
],
"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’s permissions."
"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",
@@ -354,7 +358,7 @@
],
"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’s permissions."
"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",
@@ -377,13 +381,16 @@
"policy": "endpoint",
"requestedScopes": [],
"capability": "read",
"supportedActions": "None: library documentation retrieval.",
"supportedActions": "Resolve public library names to Context7 IDs and retrieve current public documentation/code examples for a specified library and topic. These are provider-declared read-only, non-destructive lookups; no library, account, repository, or workspace mutation is exposed by the reviewed remote server definition.",
"evidence": [
"https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization"
"https://context7.com/docs/api-guide",
"https://github.com/upstash/context7/blob/master/server.json",
"https://github.com/upstash/context7/blob/master/packages/mcp/README.md",
"https://github.com/upstash/context7/blob/master/packages/mcp/src/index.ts"
],
"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."
"reviewedAt": "2026-10-09",
"liveProof": "Anonymous Paperclip connection succeeded. Discovery returned 2 read actions (resolve-library-id and query-docs), 0 write actions. Resolve React returned official React docs ID /reactjs/react.dev (source reputation High) in 2.9s; query-docs returned useEffect cleanup guidance from React documentation in 3.0s. After page reload, agent restriction remained Just agents I pick with 0 selected and the same bounded query succeeded again in 3.1s. No API key, OAuth grant, account data, private repository, provider writes, or agent run.",
"evidenceLimit": "Live proof covers anonymous MCP initialize/discovery and one narrow public React documentation lookup through Paperclip's actual Apps connection and Test dialog, including a successful repeat after reload. It does not prove elevated API-key rate limits, private-repository access, or any non-public provider content."
},
{
"app": "egnyte",
@@ -506,7 +513,7 @@
],
"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’s permissions."
"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": "gauge",
@@ -571,7 +578,7 @@
],
"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’s permissions.",
"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."
},
{
@@ -1267,7 +1274,7 @@
],
"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’s permissions."
"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",
@@ -1356,7 +1363,7 @@
],
"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’s permissions."
"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",
@@ -1471,7 +1478,7 @@
],
"reviewedAt": "2026-10-02",
"liveProof": "not-run",
"keyPermissions": "Project, branch, compute, snapshot, SQL and schema changes within the key’s reach. A project-scoped key limits access to one project with Editor rights; personal and organization keys reach every project they can access. Paperclip cannot increase an existing key’s permissions."
"keyPermissions": "Project, branch, compute, snapshot, SQL and schema changes within the key\u2019s reach. A project-scoped key limits access to one project with Editor rights; personal and organization keys reach every project they can access. Paperclip cannot increase an existing key\u2019s permissions."
},
{
"app": "notion",
@@ -1553,7 +1560,7 @@
],
"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’s permissions."
"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",
@@ -1568,7 +1575,7 @@
],
"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’s permissions."
"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",
@@ -1639,7 +1646,7 @@
],
"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’s permissions."
"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",
@@ -1707,7 +1714,7 @@
],
"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’s permissions."
"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",
@@ -1724,7 +1731,7 @@
],
"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’s permissions."
"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",
@@ -1741,7 +1748,7 @@
],
"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’s permissions."
"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",
@@ -1795,7 +1802,7 @@
],
"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’s permissions.",
"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."
},
{
@@ -1807,14 +1814,17 @@
"full_access"
],
"capability": "write",
"supportedActions": "Send email and manage domains/templates/account resources. full_access covers the MCP management tools as well as sending.",
"supportedActions": "The Resend hosted MCP exposes account/resource reads and mutations, including email sending and domain/template/account management. Provider documentation describes full_access as broad read/create/update/delete access; the separate emails:send grant does not provide a bounded account-metadata read. During qualification all 77 Write actions and all 55 Read actions were explicitly Off in Paperclip after reload.",
"evidence": [
"https://resend.com/changelog/remote-mcp-server",
"https://resend.com/changelog/oauth-support",
"https://resend.com/changelog/new-api-key-permissions",
"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"
"reviewedAt": "2026-10-09",
"liveProof": "Fresh OAuth connection succeeded under the previously approved paperclip/full_access grant. Paperclip catalog exposed 132 actions (55 Read, 77 Write). List Domains succeeded twice via UI Test as Connector Tester and returned one entry each. After the test, all action permissions were Off and stayed Off after reload; human audience Just me and agent audience Just agents I pick with zero selected also persisted. Final fresh setup after the access-marker refinement again preserved Just me and zero agents; one List Domains UI Test passed in 1.4s and final reload confirmed all 132 actions Off.",
"evidenceLimit": "Test execution was through Paperclip's UI Test dialog as Connector Tester, not a real agent adapter/task. The bounded read returned one entry; names and identifiers are omitted. No provider writes, deletes, sends, or agent runs occurred. Paperclip Off policies do not narrow the provider-side full_access OAuth grant."
},
{
"app": "sanity",
@@ -1847,7 +1857,7 @@
],
"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’s permissions."
"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",
@@ -1994,7 +2004,7 @@
],
"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’s permissions."
"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",
@@ -2039,7 +2049,7 @@
],
"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’s permissions."
"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": "superagent",
@@ -2310,7 +2320,7 @@
"offline_access"
],
"capability": "read",
"supportedActions": "Run public web search and extract content from public pages using the account’s Tavily API key and credit allowance. These are read/search operations; no provider business-record write action is exposed.",
"supportedActions": "Run public web search and extract content from public pages using the account\u2019s Tavily API key and credit allowance. These are read/search operations; no provider business-record write action is exposed.",
"evidence": [
"https://docs.tavily.com/documentation/mcp",
"https://mcp.tavily.com/.well-known/oauth-protected-resource/mcp",
@@ -2388,7 +2398,7 @@
],
"reviewedAt": "2026-10-08",
"liveProof": "not-run",
"keyPermissions": "Create an API key for the intended Firecrawl team with access to the search, scrape, parse, and any job tools you intend to use. Jobs can consume credits; Paperclip cannot increase an existing key’s permissions."
"keyPermissions": "Create an API key for the intended Firecrawl team with access to the search, scrape, parse, and any job tools you intend to use. Jobs can consume credits; Paperclip cannot increase an existing key\u2019s permissions."
},
{
"app": "firecrawl",
@@ -2491,7 +2501,7 @@
],
"reviewedAt": "2026-10-08",
"liveProof": "not-run",
"keyPermissions": "At key creation, choose read-only or read/write according to the approved task. Read/write keys can reach URL-submission and related provider actions; Paperclip cannot increase an existing key’s permissions."
"keyPermissions": "At key creation, choose read-only or read/write according to the approved task. Read/write keys can reach URL-submission and related provider actions; Paperclip cannot increase an existing key\u2019s permissions."
},
{
"app": "airtable",
@@ -2544,6 +2554,84 @@
"reviewedAt": "2026-10-08",
"evidenceLevel": "provider-documentation-reviewed",
"liveProof": "not-run"
},
{
"app": "clickup",
"method": "mcp-oauth",
"auth": "oauth",
"policy": "explicit",
"requestedScopes": [
"read",
"write"
],
"capability": "write",
"supportedActions": "Search workspace content and read tasks, lists, folders, docs, comments, members, and time entries; create or update tasks, lists, folders, docs, comments, tags, links, dependencies, time entries, and chat messages; start/stop time tracking; documented tool listing includes task deletion. Actions remain limited by the signed-in user's ClickUp permissions and authorized workspace(s).",
"evidence": [
"https://developer.clickup.com/docs/connect-an-ai-assistant-to-clickups-mcp-server",
"https://developer.clickup.com/docs/mcp-tools",
"https://mcp.clickup.com/.well-known/oauth-protected-resource/mcp",
"https://mcp.clickup.com/.well-known/oauth-authorization-server"
],
"reviewedAt": "2026-10-09",
"liveProof": "blocked-at-provider-login: retained and fresh OAuth flows both returned to blank ClickUp login after Google account selection; Password required remained.",
"evidenceLimit": "Public OAuth discovery and browser login attempt only. No provider grant, authenticated catalog, or account read."
},
{
"app": "klaviyo",
"method": "mcp-oauth",
"auth": "oauth",
"policy": "provider-default",
"requestedScopes": [],
"capability": "write",
"supportedActions": "Within the URL toolset filter, read campaign, metric, and segment context and use supported campaign write tools. The provider's campaigns:write toolset may include sending, scheduling, deletion, or other mutation tools; the authenticated catalog must be reviewed individually and Paperclip writes remain Off during qualification.",
"evidence": [
"https://developers.klaviyo.com/en/docs/connect_to_the_klaviyo_mcp_server",
"https://developers.klaviyo.com/en/docs/klaviyo_mcp_server_available_tools",
"https://developers.klaviyo.com/en/docs/authenticate_",
"https://mcp.klaviyo.com/.well-known/oauth-protected-resource/mcp",
"https://mcp.klaviyo.com/.well-known/oauth-authorization-server"
],
"reviewedAt": "2026-10-09",
"liveProof": "pending-client-approval: initial provider authorization screen reached with optional prompt-intent collection unchecked; no approval submitted.",
"providerDefaultReason": "Klaviyo's OAuth protected-resource and authorization-server metadata omit scopes_supported. The official MCP guide instead documents a toolsets URL parameter that limits exposed tools using API scope names. The source URL selects campaigns:read, campaigns:write, metrics:read, and segments:read; OAuth consent remains provider-controlled.",
"evidenceLimit": "Public discovery and initial client authorization screen only. Account/scope grant, authenticated catalog, and provider read remain unverified."
},
{
"app": "monday",
"method": "mcp-oauth",
"auth": "oauth",
"policy": "provider-default",
"requestedScopes": [],
"capability": "write",
"supportedActions": "Read and manage boards and items; create/update boards, groups, columns, items, docs, dashboards, views, forms, workflows, automations and platform agents; post item updates or send user notifications; run low-level GraphQL queries/mutations where exposed. All actions remain subject to the authorized user's permissions and account MCP limits. Verify the live catalog and keep destructive, notification, automation/workflow publication and agent lifecycle actions Off unless separately authorized.",
"evidence": [
"https://developer.monday.com/api-reference/docs/integrate-with-monday-mcp",
"https://developer.monday.com/api-reference/docs/mcp-dynamic-client-registration",
"https://developer.monday.com/api-reference/docs/mcp-dynamic-connectors",
"https://developer.monday.com/api-reference/docs/platform-mcp-tools",
"https://developer.monday.com/api-reference/docs/monday-mcp-security-overview"
],
"reviewedAt": "2026-10-09",
"liveProof": "blocked-before-consent: provider DCR HTTP 400 says redirect_uris are not allowed and asks to contact monday for callback allowlisting.",
"providerDefaultReason": "The official hosted-MCP DCR documentation describes OAuth authorization and account-admin approval for additional connectors; the hosted MCP runs as the authorizing user, inheriting that user's monday.com permissions and account MCP limits. The DCR guide does not document client-selected OAuth scope names for this route, so Paperclip sends no guessed scopes. Tool-level authority is bounded by the provider account and user's permissions; verify the live catalog before enabling actions.",
"evidenceLimit": "Public metadata and registration failure only. No client registered, consent accepted, authenticated catalog, or account read."
},
{
"app": "hugging-face",
"method": "public-mcp",
"auth": "none",
"policy": "endpoint",
"requestedScopes": [],
"capability": "read",
"supportedActions": "Anonymous public Hub lookups only: hf_whoami returns the current auth context without credential values; hub_repo_search searches public model, dataset, and Space repositories; hub_repo_details reads public repo overview, dataset structure, and bounded dataset preview; hf_fs exposes only ls, cat, attach, stat, find, and search against canonical hf:// URIs. No write, inference, dynamic Space, or job tool was advertised in the live anonymous tool catalog. Hugging Face documents that anonymous use is rate-limited.",
"evidence": [
"https://huggingface.co/docs/hub/agents-mcp",
"https://huggingface.co/blog/building-hf-mcp",
"https://github.com/huggingface/hf-mcp-server"
],
"reviewedAt": "2026-10-09",
"liveProof": "Paperclip Apps qualification passed: connected the public-mcp method from the natural Connectors catalog; initialization/discovery showed exactly 4 Read actions and 0 Write actions. Restricted agent access to Just agents I pick with 0 selected; the public method requires no identity. A bounded hf_fs search of hf://docs for MCP with limit 3 succeeded (3 public doc entries; UI preview reported one successful result row) in 0.7s. After reload, the page still showed Connected, 4 actions, Read 4, Write 0, and no agents can use this connection; the same query succeeded again in 0.8s. The Test dialog reported the direct call ran as Connector Tester; no heartbeat/agent execution or other provider tool call occurred.",
"evidenceLimit": "Qualification used only public documentation and did not use a Hugging Face account, OAuth, API key, private repo, write, inference, Space execution, or job. Anonymous endpoint was directly probed separately and returned 4 read tools, matching Paperclip discovery. The setup flow says no identity required; a one-member human audience was saved before reload, but human identity controls were not shown after reload. A root browser inspection confirmed four read actions and zero agents selected; a privacy-safe permissions screenshot was saved. Rate limits and later provider catalog changes remain outside this proof."
}
]
}
+1 -1
View File
@@ -37,7 +37,7 @@
"agentServerVersion": "1.0.88",
"agentRuntimePackage": null,
"agentRuntimeVersion": null,
"commandDigest": "sha256:aaa33c3924582b40831ec0825bf2ca9f3c08842ae5ac56540db4d25019ab5e25",
"commandDigest": "sha256:c5e4f986ed0d0ee3634881e1c3dd4765c6d405f8283f5f75c854ab387c869f94",
"requiresProviderPolicy": true
},
"claude": {
@@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
agent_runtime_package: None,
agent_runtime_version: None,
command_digest:
"sha256:aaa33c3924582b40831ec0825bf2ca9f3c08842ae5ac56540db4d25019ab5e25",
"sha256:c5e4f986ed0d0ee3634881e1c3dd4765c6d405f8283f5f75c854ab387c869f94",
requires_provider_policy: true,
},
"claude" => AcpxReleaseProfile {
@@ -53,7 +53,7 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
"agentServerVersion": "1.0.88",
"agentRuntimePackage": null,
"agentRuntimeVersion": null,
"commandDigest": "sha256:aaa33c3924582b40831ec0825bf2ca9f3c08842ae5ac56540db4d25019ab5e25",
"commandDigest": "sha256:c5e4f986ed0d0ee3634881e1c3dd4765c6d405f8283f5f75c854ab387c869f94",
"permissionPolicy": "interactive"
},
"claude": {
@@ -0,0 +1,60 @@
import { chmod, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, expect, it } from "vitest";
import { resolvePinnedOpenCodeCommand } from "./opencode-server-driver.js";
const roots: string[] = [];
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
async function installed(layout: "source" | "standalone" | "vendored", platform: string, architecture: string, name: string) {
const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-opencode-install-")));
roots.push(root);
const issuer = join(root, layout === "vendored" ? "dist/vendor/paperclip-runner/drivers/opencode/driver.js"
: layout === "source" ? "src/drivers/opencode/driver.ts" : "dist/drivers/opencode/driver.js");
const wrapper = join(root, "node_modules/opencode-ai");
const artifact = join(root, "node_modules", name);
const executable = join(artifact, "bin/opencode");
await mkdir(dirname(issuer), { recursive: true });
await mkdir(join(wrapper, "bin"), { recursive: true });
await mkdir(dirname(executable), { recursive: true });
await writeFile(join(wrapper, "package.json"), JSON.stringify({ name: "opencode-ai", version: "1.18.34", optionalDependencies: { [name]: "1.18.34" } }));
const placeholder = 'echo "postinstall was not run" >&2\nexit 1\n';
await writeFile(join(wrapper, "bin/opencode.exe"), placeholder, { mode: 0o755 });
await writeFile(join(artifact, "package.json"), JSON.stringify({ name, version: "1.18.34", os: [platform], cpu: [architecture] }));
await writeFile(executable, "#!/bin/sh\necho 1.18.34\n", { mode: 0o755 });
return { issuer: pathToFileURL(issuer), wrapper, artifact, executable, root, placeholder };
}
it.each(["source", "standalone", "vendored"] as const)("resolves the platform binary in a %s install with postinstall skipped", async layout => {
for (const [platform, architecture, name] of [
["darwin", "arm64", "opencode-darwin-arm64"],
["darwin", "x64", "opencode-darwin-x64-baseline"],
["linux", "x64", "opencode-linux-x64-baseline"],
] as const) {
const install = await installed(layout, platform, architecture, name);
expect(resolvePinnedOpenCodeCommand(install.issuer, { platform, architecture })).toBe(install.executable);
expect(await readFile(join(install.wrapper, "bin/opencode.exe"), "utf8")).toBe(install.placeholder);
}
});
it("rejects missing or mismatched qualified dependencies", async () => {
const install = await installed("vendored", "darwin", "arm64", "opencode-darwin-arm64");
await writeFile(join(install.artifact, "package.json"), JSON.stringify({ name: "opencode-darwin-arm64", version: "1.18.17", os: ["darwin"], cpu: ["arm64"] }));
expect(() => resolvePinnedOpenCodeCommand(install.issuer, { platform: "darwin", architecture: "arm64" })).toThrow("identity mismatch");
await rm(install.artifact, { recursive: true });
expect(() => resolvePinnedOpenCodeCommand(install.issuer, { platform: "darwin", architecture: "arm64" })).toThrow("qualified platform dependency is missing");
expect(() => resolvePinnedOpenCodeCommand(install.issuer, { platform: "win32", architecture: "x64" })).toThrow("not qualified");
});
it("rejects executables outside the platform package or without execute permission", async () => {
const install = await installed("source", "darwin", "arm64", "opencode-darwin-arm64");
await chmod(install.executable, 0o644);
expect(() => resolvePinnedOpenCodeCommand(install.issuer, { platform: "darwin", architecture: "arm64" })).toThrow("Pinned OpenCode runtime unavailable");
await rm(install.executable);
const outside = join(install.root, "outside");
await writeFile(outside, "#!/bin/sh\n", { mode: 0o755 });
await symlink(outside, install.executable);
expect(() => resolvePinnedOpenCodeCommand(install.issuer, { platform: "darwin", architecture: "arm64" })).toThrow("escapes its qualified platform package");
});
@@ -1,5 +1,7 @@
import { spawn, type ChildProcess } from "node:child_process";
import { randomBytes } from "node:crypto";
import { accessSync, constants, readFileSync, realpathSync, statSync } from "node:fs";
import { createRequire } from "node:module";
import {
chmod,
mkdir,
@@ -15,7 +17,7 @@ import { Agent as HttpAgent, createServer as createHttpServer, request as reques
import { Agent as HttpsAgent, request as requestHttps } from "node:https";
import { getCACertificates } from "node:tls";
import { pipeline } from "node:stream/promises";
import { dirname, join, resolve } from "node:path";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import {
CODEX_SKILLLESS_BASE_INSTRUCTIONS,
@@ -83,6 +85,69 @@ export const QUALIFIED_OPENCODE_VERSION = "1.18.34" as const;
export const QUALIFIED_OPENCODE_MODEL =
"openrouter/deepseek/deepseek-v4-flash-0731" as const;
/** Resolve a declared, pinned native dependency without PATH or install fallback. */
export function resolvePinnedOpenCodeCommand(
issuer: string | URL = import.meta.url,
target: { platform?: string; architecture?: string } = {},
): string {
try {
const platform = target.platform ?? process.platform;
const architecture = target.architecture ?? process.arch;
const packageNames: Record<string, string[]> = {
"linux-x64": ["opencode-linux-x64-baseline"],
"darwin-arm64": ["opencode-darwin-arm64"],
"darwin-x64": ["opencode-darwin-x64-baseline"],
};
const candidates = packageNames[`${platform}-${architecture}`];
if (!candidates) {
throw new Error("OpenCode native target is not qualified");
}
const manifestPath = realpathSync(createRequire(issuer).resolve("opencode-ai/package.json"));
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
if (manifest.name !== "opencode-ai" || manifest.version !== QUALIFIED_OPENCODE_VERSION) {
throw new Error(`OpenCode package version mismatch: expected opencode-ai@${QUALIFIED_OPENCODE_VERSION}`);
}
const dependencyRequire = createRequire(manifestPath);
for (const name of candidates) {
if (manifest.optionalDependencies?.[name] !== QUALIFIED_OPENCODE_VERSION) {
throw new Error("OpenCode platform dependency does not match its qualified version");
}
let platformManifest: string;
try {
platformManifest = realpathSync(dependencyRequire.resolve(`${name}/package.json`));
} catch (error) {
if (["MODULE_NOT_FOUND", "ENOENT"].includes((error as NodeJS.ErrnoException).code ?? "")) {
continue;
}
throw error;
}
const metadata = JSON.parse(readFileSync(platformManifest, "utf8"));
if (metadata.name !== name || metadata.version !== QUALIFIED_OPENCODE_VERSION
|| !Array.isArray(metadata.os) || !metadata.os.includes(platform)
|| !Array.isArray(metadata.cpu) || !metadata.cpu.includes(architecture)) {
throw new Error("OpenCode installed platform artifact identity mismatch");
}
const root = dirname(platformManifest);
const executable = realpathSync(resolve(root, "bin/opencode"));
const inside = relative(root, executable);
if (!inside || inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) {
throw new Error("OpenCode executable escapes its qualified platform package");
}
if (!statSync(executable).isFile()) {
throw new Error("OpenCode executable is not a regular file");
}
accessSync(executable, constants.X_OK);
return executable;
}
throw new Error("OpenCode qualified platform dependency is missing");
} catch (error) {
throw new Error(
`Pinned OpenCode runtime unavailable: ${(error as Error).message}. Restore Paperclip's runtime dependencies.`,
{ cause: error },
);
}
}
type DynamicToolHandler = (call: {
tool: string;
callId: string;
@@ -2255,7 +2320,7 @@ async function startRuntime(input: {
}
input.options.commandLifecycle?.beforeSpawn();
child = spawn(
input.options.command ?? "opencode",
input.options.command ?? resolvePinnedOpenCodeCommand(),
["serve", "--hostname", "127.0.0.1", "--port", String(port)],
{
cwd: input.cwd,
@@ -1651,6 +1651,15 @@ it.each(["acpx-runtime-sidecar.cjs", "opencode-app-server-proxy.cjs"] as const)(
},
);
it("requires a remote OpenCode executable before resolving controller dependencies", () => {
expect(() => runnerdLaunchProfileInternals.resolveRunnerOpenCodeExecutable({
runnerFilesystemRoot: "/provider-pack",
})).toThrow("OpenCode executable is missing from the provider pack");
expect(runnerdLaunchProfileInternals.resolveRunnerOpenCodeExecutable({
runnerFilesystemRoot: "/provider-pack", opencodeCommand: "/provider-pack/opencode",
})).toBe("/provider-pack/opencode");
});
it("derives the ACPX package authority only from the verified dist/cli layout", () => {
const runnerPackageRoot = fileURLToPath(new URL("../..", import.meta.url));
expect(
@@ -1,3 +1,4 @@
import { resolvePinnedOpenCodeCommand } from "../drivers/opencode/opencode-server-driver.js";
import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js";
import { admittedPiThinkingLevel, resolvePiThinkingLevel } from "../drivers/acpx/pi-thinking.js";
import { configuredEnvironment } from "../configured-environment.js";
@@ -3209,6 +3210,14 @@ function opencodeRunnerLaunchProfile(
};
}
function resolveRunnerOpenCodeExecutable(options: RunnerdCodexTransportOptions): string {
if (options.opencodeCommand !== undefined) return options.opencodeCommand;
if (options.runnerFilesystemRoot) {
throw new Error("runner_remote_provider_artifact_incompatible: OpenCode executable is missing from the provider pack");
}
return resolvePinnedOpenCodeCommand();
}
function authorizedToolSet(
tools: readonly Readonly<Record<string, unknown>>[],
): Record<string, unknown> {
@@ -4769,8 +4778,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
: undefined;
const opencodeExecutable =
provider === "opencode"
? (this.options.opencodeCommand ??
resolve(packageRoot, "node_modules/opencode-ai/bin/opencode.exe"))
? resolveRunnerOpenCodeExecutable(this.options)
: null;
const runnerAcpxLaunchProfile =
provider === "acpx"
@@ -4795,9 +4803,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
) {
const providerPaths = [
["provider Node", providerNodeCommand],
["OpenCode proxy", opencodeProxyPath],
["ACPX sidecar", acpxSidecarPath],
["OpenCode executable", opencodeExecutable ?? "opencode"],
...(provider === "opencode"
? [["OpenCode proxy", opencodeProxyPath], ["OpenCode executable", opencodeExecutable!]] as const
: [["ACPX sidecar", acpxSidecarPath]] as const),
] as const;
for (const [label, candidate] of providerPaths) {
if (
@@ -5396,8 +5404,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
this.options.providerNodeCommand ?? process.execPath;
const opencodeExecutable =
provider === "opencode"
? (this.options.opencodeCommand ??
resolve(packageRoot, "node_modules/opencode-ai/bin/opencode.exe"))
? resolveRunnerOpenCodeExecutable(this.options)
: null;
const runnerAcpxLaunchProfile =
provider === "acpx"
@@ -7089,6 +7096,7 @@ export const runnerdLaunchProfileInternals = Object.freeze({
acpxProviderPackageAuthority,
acpxRunnerLaunchProfile,
resolveBuildOwnedCliArtifact,
resolveRunnerOpenCodeExecutable,
maxOutboxBytes: RUNNERD_MAX_OUTBOX_BYTES,
p0ReserveBytes: RUNNERD_P0_RESERVE_BYTES,
});
@@ -1,5 +1,5 @@
{
"commandDigest": "sha256:aaa33c3924582b40831ec0825bf2ca9f3c08842ae5ac56540db4d25019ab5e25",
"commandDigest": "sha256:c5e4f986ed0d0ee3634881e1c3dd4765c6d405f8283f5f75c854ab387c869f94",
"declaration": {
"schema": "paperclip.acpx_profile_declaration.v1",
"agent": "copilot",
@@ -57,7 +57,7 @@
"nativeBackendSourceSha256": "294b9d830bc58c05985087875523db133d32cf999703140fd927e843399424db",
"nativeBackendFactorySourceSha256": "ce22825a53669f8c2db5aaa890bae94af19ede22295bda4ed7987a31d804cd8a",
"nativeInstructionsSourceSha256": "ea85be2aca8dfbd6b4042dcd7b1ed9db9a57baf8c379649a3f5d7d80ca54a6a2",
"runtimeRequestBridgeSourceSha256": "a66ee14f858ccf75c69dfbce132900a30724fe1e6baffc26b02d58a6bc5924ac",
"runtimeRequestBridgeSourceSha256": "b3807e33e2df080c7a1e4cd5ef208a7b926707af84fc7886acee1df1f70c9f2a",
"runtimeRequestFacadeSourceSha256": "e1993906ac9f47eb4ea04ae815db869ecde72f582af460cc0080308670612c8f",
"runtimeRequestNotificationSourceSha256": "6196c6175f11c69295dc00fc773201f4fdeca26c500653870253c43b81e65211",
"guardedInstallationSourceSha256": "2881b655112f165320a73753385cd54286139a0273a5fa30c1b43fc3b5981a25",
@@ -79,23 +79,26 @@ import a77 from "./app-definitions/firecrawl.json" with { type: "json" };
import a78 from "./app-definitions/parallel-search.json" with { type: "json" };
import a79 from "./app-definitions/windsor-ai.json" with { type: "json" };
import a80 from "./app-definitions/gsc-wizard.json" with { type: "json" };
import a81 from "./app-definitions/gmail.json" with { type: "json" };
import a82 from "./app-definitions/google-drive.json" with { type: "json" };
import a83 from "./app-definitions/google-docs.json" with { type: "json" };
import a84 from "./app-definitions/google-sheets.json" with { type: "json" };
import a85 from "./app-definitions/google-slides.json" with { type: "json" };
import a86 from "./app-definitions/google-calendar.json" with { type: "json" };
import a87 from "./app-definitions/google-chat.json" with { type: "json" };
import a88 from "./app-definitions/google-people.json" with { type: "json" };
import a89 from "./app-definitions/google-workspace-search.json" with { type: "json" };
import a90 from "./app-definitions/openai.json" with { type: "json" };
import a91 from "./app-definitions/openrouter.json" with { type: "json" };
import a92 from "./app-definitions/xai.json" with { type: "json" };
import a93 from "./app-definitions/google.json" with { type: "json" };
import a94 from "./app-definitions/bedrock.json" with { type: "json" };
import a95 from "./app-definitions/responses-api.json" with { type: "json" };
import a96 from "./app-definitions/messages-api.json" with { type: "json" };
import a97 from "./app-definitions/chat-completions-api.json" with { type: "json" };
import a98 from "./app-definitions/local.json" with { type: "json" };
import a81 from "./app-definitions/clickup.json" with { type: "json" };
import a82 from "./app-definitions/klaviyo.json" with { type: "json" };
import a83 from "./app-definitions/gmail.json" with { type: "json" };
import a84 from "./app-definitions/google-drive.json" with { type: "json" };
import a85 from "./app-definitions/google-docs.json" with { type: "json" };
import a86 from "./app-definitions/google-sheets.json" with { type: "json" };
import a87 from "./app-definitions/google-slides.json" with { type: "json" };
import a88 from "./app-definitions/google-calendar.json" with { type: "json" };
import a89 from "./app-definitions/google-chat.json" with { type: "json" };
import a90 from "./app-definitions/google-people.json" with { type: "json" };
import a91 from "./app-definitions/google-workspace-search.json" with { type: "json" };
import a92 from "./app-definitions/openai.json" with { type: "json" };
import a93 from "./app-definitions/openrouter.json" with { type: "json" };
import a94 from "./app-definitions/xai.json" with { type: "json" };
import a95 from "./app-definitions/google.json" with { type: "json" };
import a96 from "./app-definitions/bedrock.json" with { type: "json" };
import a97 from "./app-definitions/responses-api.json" with { type: "json" };
import a98 from "./app-definitions/messages-api.json" with { type: "json" };
import a99 from "./app-definitions/chat-completions-api.json" with { type: "json" };
import a100 from "./app-definitions/local.json" with { type: "json" };
import a101 from "./app-definitions/monday.json" with { type: "json" };
import type { AppDefinition } from "./types/app-definition.js";
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90,a91,a92,a93,a94,a95,a96,a97,a98] as AppDefinition[];
export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90,a91,a92,a93,a94,a95,a96,a97,a98,a99,a100,a101] as AppDefinition[];
+13 -10
View File
@@ -12,6 +12,7 @@ import {
getAvailableConnectionMethods,
getAppDefinitionForUrl,
getConnectableAppDefinition,
getAppStoreDefinition,
getRecommendedConnectionMethod,
recommendedDefaultsForApp,
resolveConnectionMethodServerUrl,
@@ -273,11 +274,12 @@ describe("AppDefinition catalog", () => {
"google-workspace-search",
]),
);
expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(60);
expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(63);
expect(BLOCKED_MCP_PROVIDERS.map((entry) => entry.slug)).toEqual([
"g2",
"vercel",
"zomato",
"monday",
]);
const definitionSlugs = new Set(APP_DEFINITIONS.map((app) => app.slug));
const connectableSlugs = new Set(
@@ -294,7 +296,7 @@ describe("AppDefinition catalog", () => {
),
).toEqual([]);
for (const entry of BLOCKED_MCP_PROVIDERS)
expect(connectableSlugs.has(entry.slug)).toBe(false);
expect(getAppStoreDefinition(entry.slug)).toBeNull();
});
it("separates GitHub tools from the review bot without changing the provider identity", () => {
const github = getConnectableAppDefinition("github")!;
@@ -438,15 +440,15 @@ describe("AppDefinition catalog", () => {
expect(channel("slack")?.guidanceMd).toContain("reactions");
expect(channel("slack")?.guidanceMd).toContain("direct messages");
});
it("keeps a complete, unique, dated evidence ledger for all 63 researched MCP providers", () => {
it("keeps a complete, unique, dated evidence ledger for all 67 researched MCP providers", () => {
// Ledger-wide date reflects the last full re-verification (2026-08-26);
// later provider additions carry their own research evidence, but
// bumping the shared date would overstate freshness for the other providers.
expect(SELF_SERVE_MCP_RESEARCH.verifiedAt).toBe("2026-08-26");
expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(63);
expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(67);
expect(
new Set(SELF_SERVE_MCP_RESEARCH.entries.map((entry) => entry.slug)),
).toHaveProperty("size", 63);
).toHaveProperty("size", 67);
for (const entry of SELF_SERVE_MCP_RESEARCH.entries) {
expect(new URL(entry.docsUrl).protocol).toBe("https:");
expect(new URL(entry.serverUrl).protocol).toBe("https:");
@@ -850,16 +852,17 @@ describe("AppDefinition catalog", () => {
it("withholds unverified and reserved providers from the app store without deleting their definitions", () => {
expect([...APP_STORE_HIDDEN_SLUGS].sort()).toEqual([
"beehiiv",
"bitly",
"brex",
"candid",
"clickup",
"coda",
"context7",
"egnyte",
"embat",
"kernel",
"klaviyo",
"local-falcon",
"manufact",
"monday",
"oreilly",
"planetscale",
"razorpay",
@@ -869,7 +872,7 @@ describe("AppDefinition catalog", () => {
"ticktick",
"xero",
]);
expect(APP_STORE_DEFINITIONS).toHaveLength(77);
expect(APP_STORE_DEFINITIONS).toHaveLength(79);
const connectableSlugs = new Set(
CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug),
);
@@ -928,7 +931,7 @@ describe("AppDefinition catalog", () => {
);
}
});
it("keeps every researched self-serve candidate implemented while blocked providers stay absent", () => {
it("keeps every researched self-serve candidate implemented while blocked providers stay out of Browse", () => {
const definitions = new Map(
CONNECTABLE_APP_DEFINITIONS.map((entry) => [entry.slug, entry]),
);
@@ -937,7 +940,7 @@ describe("AppDefinition catalog", () => {
true,
);
for (const blocked of BLOCKED_MCP_PROVIDERS)
expect(definitions.has(blocked.slug)).toBe(false);
expect(getAppStoreDefinition(blocked.slug)).toBeNull();
});
it("keeps all Google Workspace profiles aligned with their app, endpoint, scopes, ownership, risk, and write policy", () => {
expect(GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS).toEqual(
+4 -2
View File
@@ -10,6 +10,7 @@ export const CONNECTABLE_APP_SLUGS = new Set([
"browser-use-cloud",
"cognee",
...SELF_SERVE_MCP_CANDIDATES.map((entry) => entry.slug),
"monday",
"enterpret",
"zapier",
"arcade",
@@ -51,16 +52,17 @@ export const CONNECTABLE_APP_DEFINITIONS = APP_DEFINITIONS.filter((app) =>
*/
export const APP_STORE_HIDDEN_SLUGS = new Set([
"beehiiv",
"bitly",
"brex",
"candid",
"clickup",
"coda",
"context7",
"egnyte",
"embat",
"kernel",
"klaviyo",
"local-falcon",
"manufact",
"monday",
"oreilly",
"planetscale",
"razorpay",
+17 -12
View File
@@ -25,16 +25,20 @@
],
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
"defaults": {
"serverUrl": "https://api-ssl.bitly.com/v4/mcp"
"serverUrl": "https://api-ssl.bitly.com/v4/mcp",
"scopesHint": [
"mcp.all"
]
},
"guidanceMd": "Connect Bitly in the browser. A Bitly account with either browser authorization or an API token.",
"riskTier": "S2",
"guidanceMd": "Connect Bitly in the browser. Choose only the intended workspace. Review the discovered actions and keep writes and destructive operations disabled until explicitly needed.",
"riskTier": "S4",
"label": "Sign in with Bitly",
"consoleLinks": {
"docs": "https://dev.bitly.com/bitly-mcp/overview/quickstart/"
"docs": "https://dev.bitly.com/bitly-mcp/using-the-bitly-mcp-server/configuration-details/"
},
"warnings": [
"A Bitly account with either browser authorization or an API token."
"The mcp.all scope covers Bitly's broad MCP catalog, including link and QR management, analytics, groups, custom domains, and Bitly Sites.",
"The catalog includes publishing and deletion operations, including permanent short-link deletion, site deletion, and custom-domain changes. Review the full catalog and keep write and destructive actions Off during read-only qualification."
]
},
{
@@ -44,12 +48,12 @@
"ownershipModes": [
"customer"
],
"whenToUse": "Use a restricted customer-owned key when browser sign-in is not suitable.",
"whenToUse": "Use a customer-created Bitly API token when browser sign-in is not suitable.",
"defaults": {
"serverUrl": "https://api-ssl.bitly.com/v4/mcp"
},
"guidanceMd": "Use a customer-created Bitly key. A Bitly account with either browser authorization or an API token.",
"riskTier": "S2",
"guidanceMd": "Generate a Bitly API token under Settings → API. The token may authorize the MCP's full catalog under your Bitly account; Paperclip cannot narrow the token. Review discovered actions and keep writes and destructive operations disabled until explicitly needed.",
"riskTier": "S4",
"label": "Use an API key",
"credentialFields": [
{
@@ -59,7 +63,7 @@
"required": true,
"placeholder": "Paste your Bitly API token",
"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."
"helperMd": "A Bitly API token from Settings → API is documented for MCP bearer auth, but no restriction scope is documented; treat it as capable of the full MCP catalog under that account. Paperclip cannot narrow the token."
}
],
"keyPlacement": {
@@ -68,11 +72,12 @@
"prefix": "Bearer "
},
"consoleLinks": {
"keys": "https://dev.bitly.com/bitly-mcp/overview/quickstart/",
"docs": "https://dev.bitly.com/bitly-mcp/overview/quickstart/"
"keys": "https://bitly.com/settings/api",
"docs": "https://dev.bitly.com/bitly-mcp/using-the-bitly-mcp-server/configuration-details/"
},
"warnings": [
"A Bitly account with either browser authorization or an API token."
"This API token is not documented as a restricted MCP token. It may authorize Bitly's broad MCP catalog, including link creation/update/deletion, QR codes, Sites publishing/deletion, and custom-domain operations.",
"Review the discovered catalog and keep write and destructive actions Off during read-only qualification."
]
}
]
@@ -0,0 +1,53 @@
{
"schemaVersion": 1,
"slug": "clickup",
"name": "ClickUp",
"description": "Search ClickUp workspaces and manage tasks, docs, and conversations through the official MCP server.",
"categories": [
"productivity"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/clickup.png"
},
"urlPatterns": [
"https://mcp.clickup.com/*"
],
"docsUrl": "https://developer.clickup.com/docs/connect-an-ai-assistant-to-clickups-mcp-server",
"redirectConstraints": "https-or-loopback-http",
"methods": [
{
"key": "mcp-oauth",
"label": "Sign in with ClickUp",
"transport": "mcp_remote",
"auth": "oauth",
"ownershipModes": [
"dcr"
],
"grantKinds": [
"user"
],
"whenToUse": "Connect the provider-hosted ClickUp MCP server with browser sign-in.",
"defaults": {
"serverUrl": "https://mcp.clickup.com/mcp",
"scopesHint": [
"read",
"write"
]
},
"guidanceMd": "Choose only the ClickUp workspace needed. Confirm the target list and task before changing content, starting a timer, posting a chat message, or deleting anything.",
"warnings": [
"ClickUp's MCP may expose task, document, and chat writes, time tracking, and task deletion under the signed-in user's permissions. Review the catalog and keep write and destructive actions disabled until explicitly needed.",
"Workspace usage may be subject to plan-based rolling daily MCP limits."
],
"consoleLinks": {
"docs": "https://developer.clickup.com/docs/connect-an-ai-assistant-to-clickups-mcp-server"
},
"riskTier": "S4",
"requiredResourceFilters": [
"workspace",
"list"
]
}
]
}
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"slug": "context7",
"name": "Context7",
"description": "Look up current documentation for software libraries.",
"description": "Search public library documentation and retrieve current code examples.",
"categories": [
"developer"
],
@@ -22,12 +22,20 @@
"ownershipModes": [
"customer"
],
"whenToUse": "Use the provider-hosted connection for the quickest setup.",
"whenToUse": "Use the public hosted MCP server for read-only library documentation lookups.",
"defaults": {
"serverUrl": "https://mcp.context7.com/mcp"
},
"guidanceMd": "Connect Context7 to give agents current library documentation.",
"riskTier": "S1"
"guidanceMd": "Connect Context7 to resolve public library IDs and retrieve current documentation. Anonymous access is supported with low rate limits; this method does not configure an API key. Keep queries limited to public documentation questions and do not include credentials, personal data, or proprietary code.",
"riskTier": "S1",
"consoleLinks": {
"docs": "https://context7.com/docs/api-guide"
},
"warnings": [
"Context7 processes the query text. Do not include secrets, personal information, or proprietary code.",
"Anonymous requests have low rate limits. Provider documentation says an API key raises limits and supports private repositories; this public method does not configure a key."
]
}
]
],
"docsUrl": "https://context7.com/docs/api-guide"
}
@@ -42,6 +42,28 @@
"warnings": [
"A Hugging Face account."
]
},
{
"key": "public-mcp",
"transport": "mcp_remote",
"auth": "none",
"ownershipModes": [
"customer"
],
"whenToUse": "Use the anonymous hosted MCP endpoint for public Hugging Face Hub lookups.",
"defaults": {
"serverUrl": "https://huggingface.co/mcp"
},
"guidanceMd": "This anonymous method exposes the public Hugging Face Hub read tools for repository search, public repository details, datasets, and documentation. It does not require an account or API key and is rate-limited. Keep queries to public information; do not include secrets, personal data, private repository names, or proprietary content.",
"riskTier": "S1",
"label": "Use public Hugging Face Hub",
"consoleLinks": {
"docs": "https://huggingface.co/docs/hub/agents-mcp"
},
"warnings": [
"Anonymous requests are rate-limited by Hugging Face.",
"This method only advertises read tools for the public Hub; do not include secrets, personal data, private repository names, or proprietary content in queries."
]
}
]
}
@@ -0,0 +1,59 @@
{
"schemaVersion": 1,
"slug": "klaviyo",
"name": "Klaviyo",
"description": "Review Klaviyo campaign performance and prepare campaign changes for review.",
"categories": [
"commerce"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/klaviyo.png"
},
"urlPatterns": [
"https://mcp.klaviyo.com/*"
],
"docsUrl": "https://developers.klaviyo.com/en/docs/connect_to_the_klaviyo_mcp_server",
"redirectConstraints": "https-or-loopback-http",
"methods": [
{
"key": "mcp-oauth",
"label": "Sign in with Klaviyo",
"transport": "mcp_remote",
"auth": "oauth",
"oauthClientRegistration": "dcr",
"ownershipModes": [
"dcr"
],
"whenToUse": "Connect to the provider-hosted Klaviyo MCP server with browser sign-in.",
"defaults": {
"serverUrl": "https://mcp.klaviyo.com/mcp?toolsets=campaigns:read,campaigns:write,metrics:read,segments:read"
},
"guidanceMd": "Review campaign and metric data, then prepare campaign changes for human review. Campaign write access may expose sends and other state changes; never send or schedule a campaign without explicit authorization and an allowed Paperclip action.",
"riskTier": "S4",
"tenantFields": [
{
"key": "readOnly",
"label": "Read-only mode",
"type": "checkbox",
"advanced": true,
"defaultValue": false,
"helperMd": "Turn on to remove Klaviyo MCP tools that can modify account data. This provider-side restriction is separate from Paperclip action permissions.",
"transport": {
"location": "query",
"name": "read-only",
"format": "boolean",
"omitFalse": true
}
}
],
"consoleLinks": {
"docs": "https://developers.klaviyo.com/en/docs/connect_to_the_klaviyo_mcp_server"
},
"warnings": [
"Klaviyo's remote MCP requires an Owner, Admin, or Manager account role.",
"The campaigns:write toolset may expose campaign sending and other writes. Keep write actions Off until reviewed; the provider's Advanced read-only mode is optional and does not replace Paperclip action permissions."
]
}
]
}
@@ -0,0 +1,46 @@
{
"schemaVersion": 1,
"slug": "monday",
"name": "monday.com",
"description": "Read and manage boards and work items in monday.com.",
"categories": [
"productivity"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/monday.png",
"darkLogoUrl": "/brands/apps/monday-dark.png"
},
"urlPatterns": [
"https://mcp.monday.com/*"
],
"docsUrl": "https://developer.monday.com/api-reference/docs/integrate-with-monday-mcp",
"redirectConstraints": "https-or-loopback-http",
"methods": [
{
"key": "mcp-oauth",
"label": "Sign in with monday.com",
"transport": "mcp_remote",
"auth": "oauth",
"ownershipModes": [
"dcr"
],
"whenToUse": "Connect the hosted monday.com MCP server using OAuth and dynamic client registration.",
"defaults": {
"serverUrl": "https://mcp.monday.com/mcp"
},
"guidanceMd": "Use the selected monday.com account only. Read the target board and its columns before acting, and verify the exact board and item before any change. Keep write and destructive actions Off unless the user explicitly asks for a specific change. Never send messages, publish, or trigger automations through a board action without separate authorization.",
"warnings": [
"The hosted MCP server acts as the authorizing monday.com user and inherits that user's monday.com permissions. An account admin may also restrict MCP access to selected workspaces.",
"An additional OAuth/DCR connection can require account-admin approval for Paperclip and its exact callback URL before user authorization completes. If approval is pending, reconnect after the account admin approves it.",
"monday.com personal API tokens are documented for personal use, prototyping, and testing, and the provider says they are not intended for integrations used by other people. This connector uses the OAuth/DCR route for additional MCP clients; account-level approval may be required.",
"The tool catalog includes actions that can send notifications (optionally email), delete or activate automations, publish workflows, and create or run monday.com platform agents; a low-level tool can execute GraphQL mutations. Keep these actions Off unless separately authorized, and never run agents or change automations during qualification.",
"MCP calls count toward the account's monday.com API call limits. Keep tests bounded and avoid repeated discovery or broad queries."
],
"consoleLinks": {
"docs": "https://developer.monday.com/api-reference/docs/integrate-with-monday-mcp"
},
"riskTier": "S4"
}
]
}
@@ -31,14 +31,14 @@
"full_access"
]
},
"guidanceMd": "Connect Resend in the browser. A Resend account with access to the relevant domains.",
"riskTier": "S3",
"guidanceMd": "Sign in to the intended Resend team. The full_access grant covers reading and managing account resources, including sending email and deletion. Review the discovered actions in Paperclip before using them.",
"riskTier": "S4",
"label": "Sign in with Resend",
"consoleLinks": {
"docs": "https://resend.com/changelog/remote-mcp-server"
},
"warnings": [
"A Resend account with access to the relevant domains."
"Resend full_access grants broad account read/write/delete access; Paperclip action policies govern execution separately."
]
}
]
@@ -5,12 +5,12 @@
{ "slug": "jira", "name": "Jira", "wave": 1, "status": "self_serve", "docsUrl": "https://support.atlassian.com/atlassian-rovo-mcp-server/docs/getting-started-with-the-atlassian-remote-mcp-server/", "serverUrl": "https://mcp.atlassian.com/v1/mcp/authv2", "authMode": "dcr_cimd", "prerequisite": "An active Jira or Confluence site; tenant policy may require an administrator to approve the client.", "riskTier": "S3" },
{ "slug": "airtable", "name": "Airtable", "wave": 1, "status": "self_serve", "docsUrl": "https://support.airtable.com/articles/9897799762-using-the-airtable-mcp-server", "serverUrl": "https://mcp.airtable.com/mcp", "authMode": "dcr", "prerequisite": "An Airtable account; enterprise administrators may need to allowlist the client.", "riskTier": "S3" },
{ "slug": "beehiiv", "name": "beehiiv", "wave": 1, "status": "self_serve", "docsUrl": "https://www.beehiiv.com/features/mcp/getting-started", "serverUrl": "https://mcp.beehiiv.com/mcp", "authMode": "dcr", "prerequisite": "A beehiiv account; the subscription plan controls available write capabilities.", "riskTier": "S3" },
{ "slug": "bitly", "name": "Bitly", "wave": 1, "status": "self_serve", "docsUrl": "https://dev.bitly.com/bitly-mcp/overview/quickstart/", "serverUrl": "https://api-ssl.bitly.com/v4/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Bitly account with either browser authorization or an API token.", "riskTier": "S2" },
{ "slug": "bitly", "name": "Bitly", "wave": 1, "status": "self_serve", "docsUrl": "https://dev.bitly.com/bitly-mcp/overview/quickstart/", "serverUrl": "https://api-ssl.bitly.com/v4/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Bitly account. OAuth mcp.all or a personal API token grants the account-authorized MCP catalog, including publishing, domain management, and permanent deletion; review each action before enabling it.", "riskTier": "S4", "verifiedAt": "2026-10-09", "browserOnboarding": "completed-oauth", "liveRead": "passed: bitly_get_user through UI Test; reload and catalog refresh persisted 56 actions, zero selected agents, writes Off", "liveWrite": "not-run", "blocker": null },
{ "slug": "candid", "name": "Candid", "wave": 1, "status": "self_serve", "docsUrl": "https://learning.candid.org/getting-started-with-the-candid-mcp-connector/375441", "serverUrl": "https://mcp.candid.org/mcp", "authMode": "dcr", "prerequisite": "A Candid account with access to the MCP connector.", "riskTier": "S2" },
{ "slug": "cloudflare", "name": "Cloudflare", "wave": 1, "status": "self_serve", "docsUrl": "https://developers.cloudflare.com/agents/model-context-protocol/cloudflare/servers-for-cloudflare/", "serverUrl": "https://mcp.cloudflare.com/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Cloudflare account with access to the resources being connected.", "riskTier": "S3" },
{ "slug": "cloudinary", "name": "Cloudinary", "wave": 1, "status": "self_serve", "docsUrl": "https://cloudinary.com/documentation/cloudinary_llm_mcp", "serverUrl": "https://asset-management.mcp.cloudinary.com/mcp", "authMode": "dcr", "prerequisite": "A Cloudinary account; authorization is limited by the signed-in user's roles.", "riskTier": "S3" },
{ "slug": "coda", "name": "Coda", "wave": 1, "status": "self_serve", "docsUrl": "https://help.coda.io/hc/en-us/articles/44722769665549-Security-recommendations-for-the-Coda-MCP", "serverUrl": "https://coda.io/apis/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Coda account; the hosted MCP service is currently beta.", "riskTier": "S3" },
{ "slug": "hugging-face", "name": "Hugging Face", "wave": 1, "status": "self_serve", "docsUrl": "https://huggingface.co/docs/hub/agents-mcp", "serverUrl": "https://huggingface.co/mcp?login&gradio=none", "authMode": "dcr_cimd", "prerequisite": "A Hugging Face account.", "riskTier": "S2" },
{ "slug": "hugging-face", "name": "Hugging Face", "wave": 2, "status": "self_serve", "docsUrl": "https://huggingface.co/docs/hub/agents-mcp", "serverUrl": "https://huggingface.co/mcp", "authMode": "public", "prerequisite": "No account, OAuth grant, or API key is needed for anonymous public Hub lookup. Hugging Face says anonymous requests are rate-limited.", "riskTier": "S1", "verifiedAt": "2026-10-09", "browserOnboarding": "completed-public-no-auth", "liveRead": "passed in Paperclip UI: hf_fs search hf://docs MCP --limit 3 returned public documentation; repeated after reload (0.7s, 0.8s). UI discovery showed 4 Read / 0 Write and no agents can use the connection.", "liveWrite": "not-applicable", "blocker": null },
{ "slug": "kernel", "name": "Kernel", "wave": 1, "status": "self_serve", "docsUrl": "https://www.kernel.sh/docs/reference/mcp-server/", "serverUrl": "https://mcp.onkernel.com/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Kernel account with either browser authorization or an API key.", "riskTier": "S3" },
{ "slug": "local-falcon", "name": "Local Falcon", "wave": 1, "status": "self_serve", "docsUrl": "https://docs.localfalcon.com/", "serverUrl": "https://mcp.localfalcon.com", "authMode": "dcr", "prerequisite": "A Local Falcon account with MCP access.", "riskTier": "S2" },
{ "slug": "make", "name": "Make", "wave": 1, "status": "self_serve", "docsUrl": "https://developers.make.com/mcp-server", "serverUrl": "https://mcp.make.com", "authMode": "dcr", "prerequisite": "A Make account and access to the scenarios exposed to MCP.", "riskTier": "S3" },
@@ -21,7 +21,7 @@
{ "slug": "oreilly", "name": "O'Reilly", "wave": 1, "status": "self_serve", "docsUrl": "https://learning.oreilly.com/apidocs/mcp/content/", "serverUrl": "https://api.oreilly.com/api/content-discovery/v1/mcp/", "authMode": "dcr_or_api_key", "prerequisite": "An O'Reilly Learning subscription with MCP or API access.", "riskTier": "S2" },
{ "slug": "planetscale", "name": "PlanetScale", "wave": 1, "status": "self_serve", "docsUrl": "https://planetscale.com/docs/connect/mcp", "serverUrl": "https://mcp.pscale.dev/mcp/planetscale", "authMode": "dcr", "prerequisite": "A PlanetScale account; database and branch access are chosen during authorization.", "riskTier": "S4" },
{ "slug": "posthog", "name": "PostHog", "wave": 1, "status": "self_serve", "docsUrl": "https://posthog.com/docs/model-context-protocol", "serverUrl": "https://mcp.posthog.com/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A PostHog account; personal API keys and optional organization or project pinning are supported as alternatives and advanced controls.", "riskTier": "S3" },
{ "slug": "resend", "name": "Resend", "wave": 1, "status": "self_serve", "docsUrl": "https://resend.com/changelog/remote-mcp-server", "serverUrl": "https://mcp.resend.com/mcp", "authMode": "dcr", "prerequisite": "A Resend account with access to the relevant domains.", "riskTier": "S3" },
{ "slug": "resend", "name": "Resend", "wave": 2, "status": "self_serve", "docsUrl": "https://resend.com/changelog/remote-mcp-server", "serverUrl": "https://mcp.resend.com/mcp", "authMode": "dcr", "prerequisite": "A Resend team account. OAuth full_access grants broad resource read/write/delete and sending capability; review the discovered actions and configure Paperclip access before use.", "riskTier": "S4", "verifiedAt": "2026-10-09", "browserOnboarding": "completed-oauth", "liveRead": "passed: List Domains succeeded twice via the Paperclip Permissions Test dialog as Connector Tester; each returned one entry. No provider-returned domain content is retained here.", "liveWrite": "not-run", "blocker": null, "evidenceLimit": "The live test used Paperclip's synthetic Connector Tester, not a real agent runtime. Final reloaded Paperclip policy showed all 55 Read and 77 Write actions Off, Just me, and Just agents I pick with zero selected. No provider write, delete, or email send occurred. OAuth scope full_access remains provider-wide; disabling Paperclip actions does not narrow the granted provider token." },
{ "slug": "sentry", "name": "Sentry", "wave": 1, "status": "self_serve", "docsUrl": "https://mcp.sentry.dev/.well-known/oauth-authorization-server", "serverUrl": "https://mcp.sentry.dev/mcp", "authMode": "dcr_cimd", "prerequisite": "A Sentry account with access to the relevant organizations and projects.", "riskTier": "S3" },
{ "slug": "ticktick", "name": "TickTick", "wave": 1, "status": "self_serve", "docsUrl": "https://help.ticktick.com/articles/7438129581631995904", "serverUrl": "https://mcp.ticktick.com", "authMode": "dcr", "prerequisite": "A TickTick account with MCP access.", "riskTier": "S3" },
{ "slug": "todoist", "name": "Todoist", "wave": 1, "status": "self_serve", "docsUrl": "https://developer.todoist.com/", "serverUrl": "https://ai.todoist.net/mcp", "authMode": "dcr", "prerequisite": "A Todoist account.", "riskTier": "S3" },
@@ -64,6 +64,10 @@
{ "slug": "firecrawl", "name": "Firecrawl", "wave": 3, "status": "self_serve", "docsUrl": "https://docs.firecrawl.dev/mcp-server", "serverUrl": "https://mcp.firecrawl.dev/v2/mcp-oauth", "authMode": "dcr_or_api_key", "prerequisite": "Sign in and choose the Firecrawl team whose usage should apply. This route exposes the account-authorized tool surface; review paid jobs and action policies before execution.", "riskTier": "S4", "verifiedAt": "2026-10-08" },
{ "slug": "parallel-search", "name": "Parallel Search", "wave": 3, "status": "self_serve", "docsUrl": "https://docs.parallel.ai/integrations/mcp/search-mcp", "serverUrl": "https://search.parallel.ai/mcp", "authMode": "public", "prerequisite": "The public route exposes web_search and web_fetch. Keep source URLs, excerpts, and retrieval context in the result; this is separate from Parallel Task MCP.", "riskTier": "S3", "verifiedAt": "2026-10-08" },
{ "slug": "windsor-ai", "name": "Windsor.ai", "wave": 3, "status": "self_serve", "docsUrl": "https://mcp.windsor.ai/", "serverUrl": "https://mcp.windsor.ai/", "authMode": "dcr_or_api_key", "prerequisite": "Connect Windsor.ai, then verify which advertising, analytics, CRM, or warehouse sources are linked. Keep source account and metric definitions with results. Review any action that changes campaigns, budgets, posts, or listings.", "riskTier": "S4", "verifiedAt": "2026-10-08" },
{ "slug": "gsc-wizard", "name": "GSC Wizard", "wave": 3, "status": "self_serve", "docsUrl": "https://www.gscwizard.com/claude-search-console-mcp.html", "serverUrl": "https://mcp.gscwizard.com/mcp", "authMode": "dcr_or_api_key", "prerequisite": "Connect the GSC Wizard account and its authorized properties. Check site identity and date bounds before querying; review any URL submission or property-changing action separately.", "riskTier": "S3", "verifiedAt": "2026-10-08" }
{ "slug": "gsc-wizard", "name": "GSC Wizard", "wave": 3, "status": "self_serve", "docsUrl": "https://www.gscwizard.com/claude-search-console-mcp.html", "serverUrl": "https://mcp.gscwizard.com/mcp", "authMode": "dcr_or_api_key", "prerequisite": "Connect the GSC Wizard account and its authorized properties. Check site identity and date bounds before querying; review any URL submission or property-changing action separately.", "riskTier": "S3", "verifiedAt": "2026-10-08" },
{ "slug": "clickup", "name": "ClickUp", "wave": 1, "status": "self_serve", "docsUrl": "https://developer.clickup.com/docs/connect-an-ai-assistant-to-clickups-mcp-server", "serverUrl": "https://mcp.clickup.com/mcp", "authMode": "dcr", "prerequisite": "Sign in to a ClickUp account and select only the intended workspace. Actions remain bounded by the signed-in user’s ClickUp permissions; MCP calls may be subject to workspace-plan rate limits.", "riskTier": "S4", "verifiedAt": "2026-10-09", "browserOnboarding": "blocked-login", "liveRead": "not-run", "blocker": "Fresh and retained Google sign-in both returned to the blank ClickUp login form with Password required" },
{ "slug": "klaviyo", "name": "Klaviyo", "wave": 2, "status": "self_serve", "docsUrl": "https://developers.klaviyo.com/en/docs/connect_to_the_klaviyo_mcp_server", "serverUrl": "https://mcp.klaviyo.com/mcp?toolsets=campaigns:read,campaigns:write,metrics:read,segments:read", "authMode": "dcr", "prerequisite": "Hosted MCP requires a Klaviyo Owner, Admin, or Manager role. The server's toolsets parameter selects campaign read/write, metrics read, and segment read catalog families; exact authenticated actions still need review.", "riskTier": "S4", "verifiedAt": "2026-10-09", "browserOnboarding": "pending-client-approval", "liveRead": "not-run", "blocker": "Initial client authorization awaits user approval; account and scopes not yet enumerated" },
{ "slug": "monday", "name": "monday.com", "wave": 2, "status": "blocked", "docsUrl": "https://developer.monday.com/api-reference/docs/integrate-with-monday-mcp", "serverUrl": "https://mcp.monday.com/mcp", "authMode": "provider_approval", "prerequisite": "Provider DCR rejects the Paperclip HTTPS callback with HTTP 400: redirect_uris are not allowed. Contact monday.com to allowlist the callback before OAuth setup; account-admin approval may also be needed.", "riskTier": "S4", "verifiedAt": "2026-10-09", "browserOnboarding": "blocked-callback-allowlist", "liveRead": "not-run", "blocker": "Provider callback allowlisting required" },
{ "slug": "context7", "name": "Context7", "wave": 2, "status": "self_serve", "docsUrl": "https://context7.com/docs/api-guide", "serverUrl": "https://mcp.context7.com/mcp", "authMode": "public", "prerequisite": "No account or API key is required for anonymous remote MCP use; the official docs state anonymous requests have low rate limits. An optional API key raises limits and supports private repositories, but this curated method does not request or store one.", "riskTier": "S1", "verifiedAt": "2026-10-09", "browserOnboarding": "completed-public-no-auth", "liveRead": "passed: resolve-library-id and query-docs returned React public documentation; repeat query passed after reload with 0 agents selected", "liveWrite": "not-applicable", "blocker": null }
]
}
@@ -0,0 +1,84 @@
{
"schemaVersion": 1,
"slug": "bitly",
"name": "Bitly",
"description": "Connect Bitly's provider-hosted MCP server.",
"categories": [
"analytics"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/bitly.svg"
},
"urlPatterns": [
"https://api-ssl.bitly.com/*"
],
"docsUrl": "https://dev.bitly.com/bitly-mcp/overview/quickstart/",
"redirectConstraints": "https-or-loopback-http",
"methods": [
{
"key": "mcp-oauth",
"transport": "mcp_remote",
"auth": "oauth",
"ownershipModes": [
"dcr"
],
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
"defaults": {
"serverUrl": "https://api-ssl.bitly.com/v4/mcp",
"scopesHint": [
"mcp.all"
]
},
"guidanceMd": "Connect Bitly in the browser. Choose only the intended workspace. Review the discovered actions and keep writes and destructive operations disabled until explicitly needed.",
"riskTier": "S4",
"label": "Sign in with Bitly",
"consoleLinks": {
"docs": "https://dev.bitly.com/bitly-mcp/using-the-bitly-mcp-server/configuration-details/"
},
"warnings": [
"The mcp.all scope covers Bitly's broad MCP catalog, including link and QR management, analytics, groups, custom domains, and Bitly Sites.",
"The catalog includes publishing and deletion operations, including permanent short-link deletion, site deletion, and custom-domain changes. Review the full catalog and keep write and destructive actions Off during read-only qualification."
]
},
{
"key": "mcp-api-key",
"transport": "mcp_remote",
"auth": "api_key",
"ownershipModes": [
"customer"
],
"whenToUse": "Use a customer-created Bitly API token when browser sign-in is not suitable.",
"defaults": {
"serverUrl": "https://api-ssl.bitly.com/v4/mcp"
},
"guidanceMd": "Generate a Bitly API token under Settings → API. The token may authorize the MCP's full catalog under your Bitly account; Paperclip cannot narrow the token. Review discovered actions and keep writes and destructive operations disabled until explicitly needed.",
"riskTier": "S4",
"label": "Use an API key",
"credentialFields": [
{
"key": "authorization",
"label": "Bitly API key",
"type": "password",
"required": true,
"placeholder": "Paste your Bitly API token",
"secret": true,
"helperMd": "Bitly's API token may authorize the MCP's broad catalog under your account. Paperclip cannot narrow the token; keep write and destructive actions Off unless you explicitly need them."
}
],
"keyPlacement": {
"location": "header",
"name": "Authorization",
"prefix": "Bearer "
},
"consoleLinks": {
"keys": "https://bitly.com/settings/api",
"docs": "https://dev.bitly.com/bitly-mcp/using-the-bitly-mcp-server/configuration-details/"
},
"warnings": [
"This API token is not documented as a restricted MCP token. It may authorize Bitly's broad MCP catalog, including link creation/update/deletion, QR codes, Sites publishing/deletion, and custom-domain operations.",
"Review the discovered catalog and keep write and destructive actions Off during read-only qualification."
]
}
]
}
@@ -0,0 +1,53 @@
{
"schemaVersion": 1,
"slug": "clickup",
"name": "ClickUp",
"description": "Search ClickUp workspaces and manage tasks, docs, and conversations through the official MCP server.",
"categories": [
"productivity"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/clickup.png"
},
"urlPatterns": [
"https://mcp.clickup.com/*"
],
"docsUrl": "https://developer.clickup.com/docs/connect-an-ai-assistant-to-clickups-mcp-server",
"redirectConstraints": "https-or-loopback-http",
"methods": [
{
"key": "mcp-oauth",
"label": "Sign in with ClickUp",
"transport": "mcp_remote",
"auth": "oauth",
"ownershipModes": [
"dcr"
],
"grantKinds": [
"user"
],
"whenToUse": "Connect the provider-hosted ClickUp MCP server with browser sign-in.",
"defaults": {
"serverUrl": "https://mcp.clickup.com/mcp",
"scopesHint": [
"read",
"write"
]
},
"guidanceMd": "Choose only the ClickUp workspace needed. Confirm the target list and task before changing content, starting a timer, posting a chat message, or deleting anything.",
"warnings": [
"ClickUp's MCP may expose task, document, and chat writes, time tracking, and task deletion under the signed-in user's permissions. Review the catalog and keep write and destructive actions disabled until explicitly needed.",
"Workspace usage may be subject to plan-based rolling daily MCP limits."
],
"consoleLinks": {
"docs": "https://developer.clickup.com/docs/connect-an-ai-assistant-to-clickups-mcp-server"
},
"riskTier": "S4",
"requiredResourceFilters": [
"workspace",
"list"
]
}
]
}
@@ -0,0 +1,41 @@
{
"schemaVersion": 1,
"slug": "context7",
"name": "Context7",
"description": "Search public library documentation and retrieve current code examples.",
"categories": [
"developer"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/context7.svg",
"darkLogoUrl": "/brands/apps/context7-dark.svg"
},
"urlPatterns": [
"https://mcp.context7.com/*"
],
"docsUrl": "https://context7.com/docs/api-guide",
"methods": [
{
"key": "mcp",
"transport": "mcp_remote",
"auth": "none",
"ownershipModes": [
"customer"
],
"whenToUse": "Use the public hosted MCP server for read-only library documentation lookups.",
"defaults": {
"serverUrl": "https://mcp.context7.com/mcp"
},
"guidanceMd": "Connect Context7 to resolve public library IDs and retrieve current documentation. Anonymous access is supported with low rate limits; this method does not configure an API key. Keep queries limited to public documentation questions and do not include credentials, personal data, or proprietary code.",
"riskTier": "S1",
"consoleLinks": {
"docs": "https://context7.com/docs/api-guide"
},
"warnings": [
"Context7 processes the query text. Do not include secrets, personal information, or proprietary code.",
"Anonymous requests have low rate limits. Provider documentation says an API key raises limits and supports private repositories; this public method does not configure a key."
]
}
]
}
@@ -0,0 +1,69 @@
{
"schemaVersion": 1,
"slug": "hugging-face",
"name": "Hugging Face",
"description": "Connect Hugging Face's provider-hosted MCP server.",
"categories": [
"ai"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/hugging-face.svg"
},
"urlPatterns": [
"https://huggingface.co/*"
],
"docsUrl": "https://huggingface.co/docs/hub/agents-mcp",
"redirectConstraints": "https-or-loopback-http",
"methods": [
{
"key": "mcp-oauth",
"transport": "mcp_remote",
"auth": "oauth",
"ownershipModes": [
"dcr"
],
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
"defaults": {
"serverUrl": "https://huggingface.co/mcp?login&gradio=none",
"scopesHint": [
"read-mcp",
"read-repos",
"contribute-repos",
"jobs"
]
},
"guidanceMd": "Connect Hugging Face in the browser. A Hugging Face account.",
"riskTier": "S2",
"label": "Sign in with Hugging Face",
"consoleLinks": {
"docs": "https://huggingface.co/docs/hub/agents-mcp"
},
"warnings": [
"A Hugging Face account."
]
},
{
"key": "public-mcp",
"transport": "mcp_remote",
"auth": "none",
"ownershipModes": [
"customer"
],
"whenToUse": "Use the anonymous hosted MCP endpoint for public Hugging Face Hub lookups.",
"defaults": {
"serverUrl": "https://huggingface.co/mcp"
},
"guidanceMd": "This anonymous method exposes the public Hugging Face Hub read tools for repository search, public repository details, datasets, and documentation. It does not require an account or API key and is rate-limited. Keep queries to public information; do not include secrets, personal data, private repository names, or proprietary content.",
"riskTier": "S1",
"label": "Use public Hugging Face Hub",
"consoleLinks": {
"docs": "https://huggingface.co/docs/hub/agents-mcp"
},
"warnings": [
"Anonymous requests are rate-limited by Hugging Face.",
"This method only advertises read tools for the public Hub; do not include secrets, personal data, private repository names, or proprietary content in queries."
]
}
]
}
@@ -0,0 +1,59 @@
{
"schemaVersion": 1,
"slug": "klaviyo",
"name": "Klaviyo",
"description": "Review Klaviyo campaign performance and prepare campaign changes for review.",
"categories": [
"commerce"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/klaviyo.png"
},
"urlPatterns": [
"https://mcp.klaviyo.com/*"
],
"docsUrl": "https://developers.klaviyo.com/en/docs/connect_to_the_klaviyo_mcp_server",
"redirectConstraints": "https-or-loopback-http",
"methods": [
{
"key": "mcp-oauth",
"label": "Sign in with Klaviyo",
"transport": "mcp_remote",
"auth": "oauth",
"oauthClientRegistration": "dcr",
"ownershipModes": [
"dcr"
],
"whenToUse": "Connect to the provider-hosted Klaviyo MCP server with browser sign-in.",
"defaults": {
"serverUrl": "https://mcp.klaviyo.com/mcp?toolsets=campaigns:read,campaigns:write,metrics:read,segments:read"
},
"guidanceMd": "Review campaign and metric data, then prepare campaign changes for human review. Campaign write access may expose sends and other state changes; never send or schedule a campaign without explicit authorization and an allowed Paperclip action.",
"riskTier": "S4",
"tenantFields": [
{
"key": "readOnly",
"label": "Read-only mode",
"type": "checkbox",
"advanced": true,
"defaultValue": false,
"helperMd": "Turn on to remove Klaviyo MCP tools that can modify account data. This provider-side restriction is separate from Paperclip action permissions.",
"transport": {
"location": "query",
"name": "read-only",
"format": "boolean",
"omitFalse": true
}
}
],
"consoleLinks": {
"docs": "https://developers.klaviyo.com/en/docs/connect_to_the_klaviyo_mcp_server"
},
"warnings": [
"Klaviyo's remote MCP requires an Owner, Admin, or Manager account role.",
"The campaigns:write toolset may expose campaign sending and other writes. Keep write actions Off until reviewed; the provider's Advanced read-only mode is optional and does not replace Paperclip action permissions."
]
}
]
}
@@ -0,0 +1,46 @@
{
"schemaVersion": 1,
"slug": "monday",
"name": "monday.com",
"description": "Read and manage boards and work items in monday.com.",
"categories": [
"productivity"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/monday.png",
"darkLogoUrl": "/brands/apps/monday-dark.png"
},
"urlPatterns": [
"https://mcp.monday.com/*"
],
"docsUrl": "https://developer.monday.com/api-reference/docs/integrate-with-monday-mcp",
"redirectConstraints": "https-or-loopback-http",
"methods": [
{
"key": "mcp-oauth",
"label": "Sign in with monday.com",
"transport": "mcp_remote",
"auth": "oauth",
"ownershipModes": [
"dcr"
],
"whenToUse": "Connect the hosted monday.com MCP server using OAuth and dynamic client registration.",
"defaults": {
"serverUrl": "https://mcp.monday.com/mcp"
},
"guidanceMd": "Use the selected monday.com account only. Read the target board and its columns before acting, and verify the exact board and item before any change. Keep write and destructive actions Off unless the user explicitly asks for a specific change. Never send messages, publish, or trigger automations through a board action without separate authorization.",
"warnings": [
"The hosted MCP server acts as the authorizing monday.com user and inherits that user's monday.com permissions. An account admin may also restrict MCP access to selected workspaces.",
"An additional OAuth/DCR connection can require account-admin approval for Paperclip and its exact callback URL before user authorization completes. If approval is pending, reconnect after the account admin approves it.",
"monday.com personal API tokens are documented for personal use, prototyping, and testing, and the provider says they are not intended for integrations used by other people. This connector uses the OAuth/DCR route for additional MCP clients; account-level approval may be required.",
"The tool catalog includes actions that can send notifications (optionally email), delete or activate automations, publish workflows, and create or run monday.com platform agents; a low-level tool can execute GraphQL mutations. Keep these actions Off unless separately authorized, and never run agents or change automations during qualification.",
"MCP calls count toward the account's monday.com API call limits. Keep tests bounded and avoid repeated discovery or broad queries."
],
"consoleLinks": {
"docs": "https://developer.monday.com/api-reference/docs/integrate-with-monday-mcp"
},
"riskTier": "S4"
}
]
}
@@ -0,0 +1,45 @@
{
"schemaVersion": 1,
"slug": "resend",
"name": "Resend",
"description": "Connect Resend's provider-hosted MCP server.",
"categories": [
"communication"
],
"featured": false,
"branding": {
"logoUrl": "/brands/apps/resend.svg",
"darkLogoUrl": "/brands/apps/resend-dark.svg"
},
"urlPatterns": [
"https://mcp.resend.com/*"
],
"docsUrl": "https://resend.com/changelog/remote-mcp-server",
"redirectConstraints": "https-or-loopback-http",
"methods": [
{
"key": "mcp-oauth",
"transport": "mcp_remote",
"auth": "oauth",
"ownershipModes": [
"dcr"
],
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
"defaults": {
"serverUrl": "https://mcp.resend.com/mcp",
"scopesHint": [
"full_access"
]
},
"guidanceMd": "Sign in to the intended Resend team. The full_access grant covers reading and managing account resources, including sending email and deletion. Review the discovered actions in Paperclip before using them.",
"riskTier": "S4",
"label": "Sign in with Resend",
"consoleLinks": {
"docs": "https://resend.com/changelog/remote-mcp-server"
},
"warnings": [
"Resend full_access grants broad account read/write/delete access; Paperclip action policies govern execution separately."
]
}
]
}
+1
View File
@@ -99,6 +99,7 @@
"nice-grpc": "2.1.17",
"nice-grpc-common": "2.0.4",
"open": "^11.0.4",
"opencode-ai": "1.18.34",
"pino": "^10.0.0",
"pino-http": "^11.0.0",
"pino-pretty": "^13.1.3",
@@ -103,6 +103,16 @@ describe("server package build script", () => {
);
});
it("ships the pinned OpenCode dependency resolved by the vendored Runner", () => {
const server = JSON.parse(readFileSync(packageJsonPath, "utf8"));
const runner = JSON.parse(readFileSync(new URL(
"../../../packages/paperclip-runner/package.json", import.meta.url,
), "utf8"));
// createRequire resolution is dynamic and is not covered by the import scan.
expect(server.dependencies["opencode-ai"]).toBe("1.18.34");
expect(server.dependencies["opencode-ai"]).toBe(runner.dependencies["opencode-ai"]);
});
it("loads runner source when the source server starts before workspace builds", () => {
const shim = readFileSync(runnerShimPath, "utf8");
+135 -110
View File
@@ -12563,122 +12563,147 @@ describeEmbeddedPostgres("tool access service", () => {
expect(JSON.stringify(completed)).not.toContain("supabase-dcr-secret");
});
it("preserves the provider's DCR client-auth ordering for Miro token exchange", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_MIRO_CLIENT_ID", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_MIRO_CLIENT_SECRET", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
const company = await createCompany(db);
await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]);
const service = createTestToolAccessService(db);
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "miro",
connectionMethodKey: "mcp-oauth",
name: "Miro DCR",
});
const redirectUri = "https://paperclip.example/api/tools/oauth/callback";
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
const href = String(url);
if (
href === "https://mcp.miro.com/.well-known/oauth-protected-resource"
) {
return mcpHttpResponse({
resource: "https://mcp.miro.com/",
authorization_servers: ["https://mcp.miro.com/"],
});
it.each(["default", "none", "agent", "company"] as const)(
"negotiates JSON OAuth tokens and preserves MCP agent reach (%s)", async (reach) => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_MIRO_CLIENT_ID", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_MIRO_CLIENT_SECRET", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", "");
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", "");
const company = await createCompany(db);
await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]);
const service = createTestToolAccessService(db);
const connected = await service.connectGalleryApp(company.id, {
galleryKey: "miro",
connectionMethodKey: "mcp-oauth",
name: "Miro DCR",
});
const agent = await createAgent(db, company.id);
const installs = reach === "company" || reach === "default"
? [{ targetType: "company" as const, targetId: company.id }]
: reach === "agent"
? [{ targetType: "agent" as const, targetId: agent.id }]
: [];
if (reach !== "default") {
await service.putConnectionInstalls(connected.connectionId, { installs });
}
if (
href === "https://mcp.miro.com/.well-known/oauth-authorization-server"
) {
return mcpHttpResponse({
issuer: "https://mcp.miro.com/",
authorization_endpoint: "https://mcp.miro.com/authorize",
token_endpoint: "https://mcp.miro.com/token",
registration_endpoint: "https://mcp.miro.com/register",
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: [
const redirectUri = "https://paperclip.example/api/tools/oauth/callback";
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
const href = String(url);
if (
href === "https://mcp.miro.com/.well-known/oauth-protected-resource"
) {
return mcpHttpResponse({
resource: "https://mcp.miro.com/",
authorization_servers: ["https://mcp.miro.com/"],
});
}
if (
href === "https://mcp.miro.com/.well-known/oauth-authorization-server"
) {
return mcpHttpResponse({
issuer: "https://mcp.miro.com/",
authorization_endpoint: "https://mcp.miro.com/authorize",
token_endpoint: "https://mcp.miro.com/token",
registration_endpoint: "https://mcp.miro.com/register",
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: [
"client_secret_post",
"client_secret_basic",
],
});
}
if (href === "https://mcp.miro.com/register") {
const requestBody = JSON.parse(String(init?.body)) as Record<
string,
unknown
>;
expect(requestBody.token_endpoint_auth_method).toBe(
"client_secret_post",
"client_secret_basic",
],
});
}
if (href === "https://mcp.miro.com/register") {
const requestBody = JSON.parse(String(init?.body)) as Record<
string,
unknown
>;
expect(requestBody.token_endpoint_auth_method).toBe(
"client_secret_post",
);
return mcpHttpResponse({
client_id: "miro-dcr-client",
client_secret: "miro-dcr-secret",
redirect_uris: [redirectUri],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "client_secret_post",
});
}
if (href === "https://mcp.miro.com/token") {
const headers = new Headers(init?.headers);
const body = init?.body as URLSearchParams;
expect(headers.get("authorization")).toBeNull();
expect(body.get("client_id")).toBe("miro-dcr-client");
expect(body.get("client_secret")).toBe("miro-dcr-secret");
expect(body.get("code")).toBe("miro-code");
return mcpHttpResponse({
access_token: "miro-access-token",
refresh_token: "miro-refresh-token",
expires_in: 3600,
token_type: "Bearer",
});
}
if (href === "https://mcp.miro.com/") {
expect(new Headers(init?.headers).get("authorization")).toBe(
"Bearer miro-access-token",
);
return mcpHttpResponse({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
result: {
tools: [{ name: "whoami", annotations: { readOnlyHint: true } }],
},
});
}
throw new Error(`unexpected fetch ${href}`);
});
);
return mcpHttpResponse({
client_id: "miro-dcr-client",
client_secret: "miro-dcr-secret",
redirect_uris: [redirectUri],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "client_secret_post",
});
}
if (href === "https://mcp.miro.com/token") {
const headers = new Headers(init?.headers);
const body = init?.body as URLSearchParams;
// Match providers that negotiate JSON only when explicitly requested.
if (headers.get("accept") !== "application/json") {
return new Response("access_token=miro-access-token", {
headers: { "content-type": "application/x-www-form-urlencoded" },
});
}
expect(headers.get("authorization")).toBeNull();
expect(body.get("client_id")).toBe("miro-dcr-client");
expect(body.get("client_secret")).toBe("miro-dcr-secret");
expect(body.get("code")).toBe("miro-code");
return mcpHttpResponse({
access_token: "miro-access-token",
refresh_token: "miro-refresh-token",
expires_in: 3600,
token_type: "Bearer",
});
}
if (href === "https://mcp.miro.com/") {
expect(new Headers(init?.headers).get("authorization")).toBe(
"Bearer miro-access-token",
);
return mcpHttpResponse({
jsonrpc: "2.0",
id: "paperclip-catalog-refresh",
result: {
tools: [{ name: "whoami", annotations: { readOnlyHint: true } }],
},
});
}
throw new Error(`unexpected fetch ${href}`);
});
const started = await service.startOAuth(
company.id,
connected.connectionId,
{
const started = await service.startOAuth(
company.id,
connected.connectionId,
{
redirectUri,
actor: { actorType: "user", actorId: "board" },
},
);
const state = new URL(started.authorizationUrl).searchParams.get("state");
expect(state).toBeTruthy();
const completed = await service.completeOAuthCallback({
state: state!,
code: "miro-code",
redirectUri,
actor: { actorType: "user", actorId: "board" },
},
);
const state = new URL(started.authorizationUrl).searchParams.get("state");
expect(state).toBeTruthy();
const completed = await service.completeOAuthCallback({
state: state!,
code: "miro-code",
redirectUri,
actor: { actorType: "user", actorId: "board" },
});
});
expect(completed.actions.readOnly).toEqual([
expect.objectContaining({ toolName: "whoami", riskLevel: "read" }),
]);
const [connection] = await db
.select()
.from(toolConnections)
.where(eq(toolConnections.id, connected.connectionId));
expect(connection.config).toMatchObject({
oauth: { clientTokenEndpointAuthMethod: "client_secret_post" },
});
expect(JSON.stringify(connection.config)).not.toContain("miro-dcr-secret");
expect(JSON.stringify(completed)).not.toContain("miro-dcr-secret");
});
expect(completed.actions.readOnly).toEqual([
expect.objectContaining({ toolName: "whoami", riskLevel: "read" }),
]);
const [connection] = await db
.select()
.from(toolConnections)
.where(eq(toolConnections.id, connected.connectionId));
expect(connection.config).toMatchObject({
oauth: { clientTokenEndpointAuthMethod: "client_secret_post" },
});
expect(JSON.stringify(connection.config)).not.toContain("miro-dcr-secret");
expect(JSON.stringify(completed)).not.toContain("miro-dcr-secret");
const installed = await db.select().from(toolConnectionInstalls)
.where(eq(toolConnectionInstalls.connectionId, connected.connectionId));
expect(installed.map(({ targetType, targetId }) => ({ targetType, targetId }))).toEqual(installs);
const [profile] = await db.select().from(toolProfiles)
.where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`));
const bindings = await db.select().from(toolProfileBindings)
.where(eq(toolProfileBindings.profileId, profile.id));
expect(bindings.map(({ targetType, targetId }) => ({ targetType, targetId }))).toEqual(installs);
},
);
it("accepts provider-added DCR grants without adopting them", async () => {
vi.stubEnv("PAPERCLIP_TOOL_OAUTH_HUGGING_FACE_CLIENT_ID", "");
+25 -2
View File
@@ -10665,6 +10665,8 @@ export function toolAccessService(
});
const headers: Record<string, string> = {
"content-type": "application/x-www-form-urlencoded",
// Some providers otherwise return a legacy form-encoded token response.
accept: "application/json",
};
if (tokenEndpointAuthMethod === "client_secret_basic") {
if (!input.clientSecret) {
@@ -15350,7 +15352,13 @@ export function toolAccessService(
: suggestedAgentIds.length > 0
? { agentIds: suggestedAgentIds }
: "all_agents";
const remoteMcpAccess = isRemoteMcpConnectorMethod(input.connection.config.sourceTemplateKey, input.connection.config.connectionMethodKey);
// An explicitly saved empty Access selection means no agents. A missing
// selection must still use managed OAuth's subject/recommended defaults.
const remoteMcpAccess = isRemoteMcpConnectorMethod(
input.connection.config.sourceTemplateKey,
input.connection.config.connectionMethodKey,
) || (input.connection.transport === "mcp_remote"
&& input.connection.config.mcpAgentAccessConfigured === true);
const access: FinishToolApp["access"] = deferTaskAccess
? { agentIds: [] }
: installs.length === 0
@@ -18828,12 +18836,26 @@ export function toolAccessService(
);
}
}
const markMcpAccessConfigured = connection.transport === "mcp_remote"
&& connection.config.mcpAgentAccessConfigured !== true;
const accessExtensions: Array<{
targetType: "company" | "agent";
targetId: string;
profileId: string;
}> = [];
await db.transaction(async (tx) => {
if (markMcpAccessConfigured) {
// Preserve an explicit zero-agent selection through OAuth. Merge the
// marker in SQL so concurrent credential/config changes are retained.
await tx.update(toolConnections).set({
config: sql`${toolConnections.config} || '{"mcpAgentAccessConfigured":true}'::jsonb`,
transportConfig: sql`${toolConnections.transportConfig} || '{"mcpAgentAccessConfigured":true}'::jsonb`,
updatedAt: now(),
}).where(and(
eq(toolConnections.companyId, connection.companyId),
eq(toolConnections.id, connection.id),
));
}
const existing = await tx
.select()
.from(toolConnectionInstalls)
@@ -18942,7 +18964,7 @@ export function toolAccessService(
});
}
}
if (removeIds.length > 0 || additions.length > 0) {
if (markMcpAccessConfigured || removeIds.length > 0 || additions.length > 0) {
const binding = actorBinding(actor);
await tx.insert(toolAccessAuditEvents).values({
companyId: connection.companyId,
@@ -18953,6 +18975,7 @@ export function toolAccessService(
outcome: "success",
reasonCode: "installs_changed",
details: {
...(markMcpAccessConfigured ? { mcpAgentAccessConfigured: true } : {}),
added: additions.map((install) => ({
targetType: install.targetType,
targetId: install.targetId,
+33 -4
View File
@@ -178,11 +178,40 @@ 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);
// Return through the UI so a page reload cannot hide a stale catalog cache.
const connectionsResponse = await request.get(`/api/companies/${seed.companyId}/tools/connections`);
expect(connectionsResponse.ok(), `list connections failed ${connectionsResponse.status()}`).toBe(true);
const connectionsPayload = await connectionsResponse.json();
const savedConnection = connectionsPayload.connections.find(
(connection: { config?: { url?: string } }) => connection.config?.url === mock.url,
);
expect(savedConnection?.id).toBeTruthy();
const mockPort = new URL(savedConnection.config.url).port;
expect(mockPort).toBeTruthy();
// View the exact saved connection and confirm its discovered actions survive a reload.
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 });
await expect(page).toHaveURL(
new RegExp(`/${seed.prefix}/apps/${savedConnection.id}/permissions$`),
{ timeout: 15_000 },
);
const savedHeading = page.getByRole("heading", { level: 1 });
await expect(savedHeading).toBeVisible();
const savedHeadingText = (await savedHeading.textContent())?.trim();
expect(savedHeadingText).toBeTruthy();
expect(savedHeadingText).toContain(mockPort);
await expect(page.getByRole("heading", { name: "Actions", exact: true })).toBeVisible();
await expect(page.getByText("List widgets", { exact: true })).toBeVisible();
await expect(page.getByText("Create widget", { exact: true })).toBeVisible();
await page.reload();
await expect(page).toHaveURL(
new RegExp(`/${seed.prefix}/apps/${savedConnection.id}/permissions$`),
{ timeout: 15_000 },
);
await expect(page.getByRole("heading", { name: savedHeadingText, exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Actions", exact: true })).toBeVisible();
await expect(page.getByText("List widgets", { exact: true })).toBeVisible();
await expect(page.getByText("Create widget", { exact: true })).toBeVisible();
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-06-permissions.png`, fullPage: true });
});
test("Expired key → health sweep → Needs attention → reconnect → green", async ({ page, request }) => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+21 -2
View File
@@ -47,7 +47,7 @@
{
"slug": "bitly",
"provider": "Bitly",
"catalogVisible": false,
"catalogVisible": true,
"localAsset": "/brands/apps/bitly.svg"
},
{
@@ -105,7 +105,7 @@
{
"slug": "context7",
"provider": "Context7",
"catalogVisible": false,
"catalogVisible": true,
"localAsset": "/brands/apps/context7.svg",
"darkAsset": "/brands/apps/context7-dark.svg"
},
@@ -640,6 +640,25 @@
"provider": "Windsor.ai",
"catalogVisible": true,
"localAsset": "/brands/apps/windsor-ai.png"
},
{
"slug": "clickup",
"provider": "ClickUp",
"catalogVisible": false,
"localAsset": "/brands/apps/clickup.png"
},
{
"slug": "klaviyo",
"provider": "Klaviyo",
"catalogVisible": false,
"localAsset": "/brands/apps/klaviyo.png"
},
{
"slug": "monday",
"provider": "monday.com",
"catalogVisible": false,
"localAsset": "/brands/apps/monday.png",
"darkAsset": "/brands/apps/monday-dark.png"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -7,6 +7,7 @@ import {
isConnectionDefinitionUnavailable,
isVercelConnectUnavailable,
readConnectionIntentOAuthOutcome,
retainedResumeMatches,
retainedReconnectMatches,
requestedConnectionSetupResolution,
requestedConnectionEntry,
@@ -91,12 +92,18 @@ describe("retained reconnect definition lookup", () => {
requestedAppKey: "github",
galleryApps: [],
reconnectConnection: connection,
resumeConnection: null,
resumeApplication: null,
completedResumeConnectionId: null,
applications: [githubApplication],
})?.slug).toBe("github");
expect(requestedConnectionEntry({
requestedAppKey: "notion",
galleryApps: [],
reconnectConnection: connection,
resumeConnection: null,
resumeApplication: null,
completedResumeConnectionId: null,
applications: [githubApplication],
})).toBeNull();
});
@@ -141,6 +148,9 @@ describe("retained reconnect definition lookup", () => {
requestedAppKey: "github",
galleryApps: [],
reconnectConnection: null,
resumeConnection: null,
resumeApplication: null,
completedResumeConnectionId: null,
applications: [],
})).toBeNull();
const visibleNotion = getConnectableAppDefinition("notion")!;
@@ -148,10 +158,40 @@ describe("retained reconnect definition lookup", () => {
requestedAppKey: "notion",
galleryApps: [visibleNotion],
reconnectConnection: null,
resumeConnection: null,
resumeApplication: null,
completedResumeConnectionId: null,
applications: [],
})).toBe(visibleNotion);
});
it.each([
["draft", true],
["active", true],
["disabled", false],
["archived", false],
] as const)("allows retained resume status %s only when appropriate", (status, expected) => {
const resumeConnection = {
id: "connection-1",
companyId: "company-1",
applicationId: "app-clickup",
status,
config: { sourceTemplateKey: "clickup" },
} as unknown as ToolConnection;
const resumeApplication = {
id: "app-clickup",
companyId: "company-1",
metadata: { sourceTemplateKey: "clickup" },
} as unknown as ToolApplication;
expect(retainedResumeMatches({
requestedAppKey: "clickup",
resumeConnection,
resumeApplication,
completedResumeConnectionId: "connection-1",
})).toBe(expected);
});
it("does not apply fresh-setup availability to an exact retained reconnect", () => {
expect(isVercelConnectUnavailable({
credentialSource: "vercel_connect",
@@ -81,7 +81,7 @@ import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHand
import { redactUrlSecrets } from "@/lib/redact-url-secrets";
import { askFirstCatalogEntryIdsFor } from "./connection-defaults";
import { AppLogo } from "@/pages/apps/AppLogo";
import { appApplicationSourceSlug } from "@/pages/apps/app-definition-display";
import { appApplicationSourceSlug, appConnectionSourceSlug } from "@/pages/apps/app-definition-display";
import { UnverifiedServerBadge } from "@/pages/apps/UnverifiedServerBadge";
import {
appSourceConnectHref,
@@ -209,10 +209,19 @@ export function requestedConnectionEntry(input: {
requestedAppKey: string;
galleryApps: readonly AppDefinition[];
reconnectConnection: ToolConnection | null;
resumeConnection: ToolConnection | null;
resumeApplication: ToolApplication | null;
completedResumeConnectionId: string | null;
applications: readonly ToolApplication[];
}): AppDefinition | null {
const visible = input.galleryApps.find((candidate) => candidate.slug === input.requestedAppKey);
if (visible) return visible;
if (retainedResumeMatches({
requestedAppKey: input.requestedAppKey,
resumeConnection: input.resumeConnection,
resumeApplication: input.resumeApplication,
completedResumeConnectionId: input.completedResumeConnectionId,
})) return getConnectableAppDefinition(input.requestedAppKey);
if (!input.reconnectConnection) return null;
const application = input.applications.find(
(candidate) => candidate.id === input.reconnectConnection?.applicationId,
@@ -221,6 +230,27 @@ export function requestedConnectionEntry(input: {
return getConnectableAppDefinition(input.requestedAppKey);
}
export function retainedResumeMatches(input: {
requestedAppKey: string;
resumeConnection: ToolConnection | null;
resumeApplication: ToolApplication | null;
completedResumeConnectionId: string | null;
}): boolean {
const { requestedAppKey, resumeConnection, resumeApplication, completedResumeConnectionId } = input;
return Boolean(
resumeConnection
&& (
resumeConnection.status === "draft"
|| (resumeConnection.status === "active" && resumeConnection.id === completedResumeConnectionId)
)
&& resumeApplication
&& resumeConnection.applicationId === resumeApplication.id
&& resumeConnection.companyId === resumeApplication.companyId
&& appApplicationSourceSlug(resumeApplication) === requestedAppKey
&& appConnectionSourceSlug(resumeConnection) === requestedAppKey,
);
}
export function retainedReconnectMatches(input: {
requestedAppKey: string | undefined;
byo: boolean;
@@ -735,6 +765,10 @@ function StandardConnectionSetupFlow({
const customerClientResumeRef = useRef<string | null>(null);
const hydratedResumeConnectionIdRef = useRef<string | null>(null);
const [hydratedResumeConnectionId, setHydratedResumeConnectionId] = useState<string | null>(null);
// Finishing a resumed unauthenticated/API-key draft activates that exact row.
// Permit it through retained-draft validation only after this flow's own
// finish request succeeds; an initially active resume remains invalid.
const [completedResumeConnectionId, setCompletedResumeConnectionId] = useState<string | null>(null);
const oauthPopupRef = useRef<Window | null>(null);
const [dialogOAuthConnectionId, setDialogOAuthConnectionId] = useState<string | null>(null);
const [authorizationFallbackUrl, setAuthorizationFallbackUrl] = useState<string | null>(null);
@@ -1183,6 +1217,26 @@ function StandardConnectionSetupFlow({
: null,
[connectionsQuery.data, resumeConnectionId],
);
const resumeApplication = useMemo(
() => resumeConnection
? (applicationsQuery.data?.applications ?? []).find(
(application) => application.id === resumeConnection.applicationId,
) ?? null
: null,
[applicationsQuery.data, resumeConnection],
);
const completedRouteResumeConnectionId = routeStage === "complete"
&& resumeConnection?.status === "active"
? resumeConnection.id
: null;
const resumeSourceMatches = requestedAppKey
? retainedResumeMatches({
requestedAppKey,
resumeConnection,
resumeApplication,
completedResumeConnectionId: completedResumeConnectionId ?? completedRouteResumeConnectionId,
})
: false;
const identityConnection = resumeConnection ?? reconnectConnection;
const reconnectGrantKind: ConnectionGrantKind | null = identityConnection
? identityConnection.credentialPolicy === "per_user"
@@ -1496,15 +1550,33 @@ function StandardConnectionSetupFlow({
useEffect(() => {
if (!requestedAppKey || galleryQuery.isLoading || !galleryQuery.data) return;
if (reconnectConnectionId && (
if ((resumeConnectionId || reconnectConnectionId) && (
!connectionsQuery.isFetchedAfterMount
|| !applicationsQuery.isFetchedAfterMount
)) return;
// A resume URL is only a recovery route. Invalid or cross-provider drafts
// must remain on the blocked recovery screen instead of falling through to
// fresh setup for a hidden provider.
if (
resumeConnectionId
&& routeStage === "complete"
&& resumeConnection?.status === "active"
&& completedResumeConnectionId !== resumeConnectionId
&& resumeSourceMatches
) {
navigate(`/apps/${resumeConnection.id}/permissions`, { replace: true });
return;
}
if (resumeConnectionId && !resumeSourceMatches) return;
const requestedEntry = requestedConnectionEntry({
requestedAppKey,
galleryApps: galleryQuery.data.apps,
reconnectConnection,
resumeConnection,
resumeApplication,
completedResumeConnectionId,
applications: applicationsQuery.data?.applications ?? [],
});
const requestedEntryAdvertisesManagedConnector = Boolean(
@@ -1649,6 +1721,12 @@ function StandardConnectionSetupFlow({
reconnectConnection,
reconnectConnectionId,
reconnectSourceMatches,
resumeConnection,
resumeApplication,
resumeSourceMatches,
completedResumeConnectionId,
completedRouteResumeConnectionId,
routeStage,
resumeConnectionId,
fullRequestedDefinition,
requestedAppKey,
@@ -1767,6 +1845,9 @@ function StandardConnectionSetupFlow({
return finished;
},
onSuccess: async (_finished, input) => {
if (resumeConnectionId && input.result.connectionId === resumeConnectionId) {
setCompletedResumeConnectionId(resumeConnectionId);
}
await queryClient.invalidateQueries({ queryKey: ["tools"] });
await queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
setAppStep("success");
@@ -1837,6 +1918,26 @@ function StandardConnectionSetupFlow({
);
}
if (
resumeConnectionId
&& connectionsQuery.isFetchedAfterMount
&& applicationsQuery.isFetchedAfterMount
&& resumeConnection
&& !resumeSourceMatches
) {
return (
<div className="mx-auto max-w-xl rounded-xl border border-border bg-card p-6">
<h2 className="text-lg font-semibold text-foreground">This setup can’t be resumed</h2>
<p className="mt-2 text-sm text-muted-foreground">
This saved draft does not match the requested provider. The connection was not changed.
</p>
<Button type="button" variant="outline" className="mt-5" onClick={() => navigate("/apps")}>
Back to apps
</Button>
</div>
);
}
if (
reconnectConnectionId
&& connectionsQuery.isFetchedAfterMount
@@ -2608,7 +2709,11 @@ function StandardConnectionSetupFlow({
installCount: installAgentIds.size,
lockedAgentId: requestedAgentId ?? null,
})}
onDone={onCancel ?? (() => navigate("/apps"))}
onDone={onCancel ?? (() => navigate(
host === "page" && connectResult?.connectionId
? `/apps/${connectResult.connectionId}/permissions`
: "/apps",
))}
/>
)}
</div>
+281
View File
@@ -2709,6 +2709,287 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
);
});
it("resumes a hidden provider draft only after matching its company-visible application", async () => {
const connectionId = "22222222-2222-4222-8222-222222222222";
mockSearch.value = `source=clickup&resume=${connectionId}`;
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
listApplicationsMock.mockResolvedValueOnce({
applications: [{
id: "app-clickup",
status: "active",
metadata: { sourceTemplateKey: "clickup" },
}],
});
listConnectionsMock.mockResolvedValueOnce({
connections: [{
id: connectionId,
applicationId: "app-clickup",
authKind: "oauth",
credentialPolicy: "per_user",
status: "draft",
config: { sourceTemplateKey: "clickup" },
transportConfig: {},
}],
});
startOAuthMock.mockResolvedValueOnce({
connectionId,
provider: "clickup",
authorizationUrl: "https://app.clickup.com/api?state=resumed",
expiresAt: "2099-01-01T00:00:00.000Z",
});
await render();
await vi.waitFor(() => {
expect(container.textContent).toContain("Finish connecting ClickUp");
});
expect(getAppStoreDefinition("clickup")).toBeNull();
await act(async () => {
buttonByText("Finish with ClickUp")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).toHaveBeenCalledWith(connectionId, { asCurrentUser: true });
});
it("keeps a resumed unauthenticated draft through its finish-time active transition", async () => {
const connectionId = "22222222-2222-4222-8222-222222222222";
const draft = {
id: connectionId,
companyId: "company-1",
applicationId: "app-context7",
authKind: "none",
credentialPolicy: "shared",
status: "draft",
transport: "mcp_remote",
config: { sourceTemplateKey: "context7", connectionMethodKey: "mcp", url: "https://mcp.context7.com/mcp" },
transportConfig: { sourceTemplateKey: "context7" },
};
mockSearch.value = `source=context7&resume=${connectionId}`;
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
listApplicationsMock.mockResolvedValue({
applications: [{
id: "app-context7",
companyId: "company-1",
status: "active",
metadata: { sourceTemplateKey: "context7" },
}],
});
listConnectionsMock
.mockResolvedValueOnce({ connections: [draft] })
.mockResolvedValue({ connections: [{ ...draft, status: "active" }] });
connectAppMock.mockResolvedValueOnce({
connectionId,
connection: draft,
application: { name: "Context7" },
catalog: [{ id: "tool-context7", name: "resolve-library-id", status: "active" }],
actions: { readOnly: [{ catalogEntryId: "tool-context7", riskLevel: "read" }], canMakeChanges: [] },
});
await render();
await vi.waitFor(() => expect(container.textContent).toContain("Connect Context7"));
await act(async () => {
buttonByText("Connect")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await vi.waitFor(() => expect(finishAppMock).toHaveBeenCalledOnce());
await vi.waitFor(() => {
expect(container.textContent).not.toContain("This setup can’t be resumed");
expect(container.textContent).toContain("Context7 is ready.");
});
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ resumeConnectionId: connectionId }));
expect(finishAppMock).toHaveBeenCalledWith("company-1", connectionId, expect.any(Object));
expect(startOAuthMock).not.toHaveBeenCalled();
await act(async () => {
buttonByText("View connection")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockNavigate).toHaveBeenLastCalledWith(`/apps/${connectionId}/permissions`);
});
it("restores a valid completed resume URL to the exact connection permissions page", async () => {
const connectionId = "22222222-2222-4222-8222-222222222222";
mockSearch.value = `source=clickup&stage=complete&resume=${connectionId}`;
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
listApplicationsMock.mockResolvedValueOnce({
applications: [{
id: "app-clickup",
companyId: "company-1",
status: "active",
metadata: { sourceTemplateKey: "clickup" },
}],
});
listConnectionsMock.mockResolvedValueOnce({
connections: [{
id: connectionId,
companyId: "company-1",
applicationId: "app-clickup",
authKind: "oauth",
credentialPolicy: "per_user",
status: "active",
config: { sourceTemplateKey: "clickup" },
transportConfig: { sourceTemplateKey: "clickup" },
}],
});
await render();
await vi.waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(`/apps/${connectionId}/permissions`, { replace: true });
});
expect(connectAppMock).not.toHaveBeenCalled();
expect(finishAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).not.toHaveBeenCalled();
});
it("does not treat a draft connection as completed from the URL stage", async () => {
const connectionId = "22222222-2222-4222-8222-222222222222";
mockSearch.value = `source=clickup&stage=complete&resume=${connectionId}`;
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
listApplicationsMock.mockResolvedValueOnce({
applications: [{
id: "app-clickup",
companyId: "company-1",
status: "active",
metadata: { sourceTemplateKey: "clickup" },
}],
});
listConnectionsMock.mockResolvedValueOnce({
connections: [{
id: connectionId,
companyId: "company-1",
applicationId: "app-clickup",
authKind: "oauth",
credentialPolicy: "per_user",
status: "draft",
config: { sourceTemplateKey: "clickup" },
transportConfig: { sourceTemplateKey: "clickup" },
}],
});
await render();
await vi.waitFor(() => expect(container.textContent).toContain("Finish connecting ClickUp"));
expect(mockNavigate).not.toHaveBeenCalledWith(`/apps/${connectionId}/permissions`, { replace: true });
expect(container.textContent).not.toContain("ClickUp is ready.");
expect(connectAppMock).not.toHaveBeenCalled();
expect(finishAppMock).not.toHaveBeenCalled();
});
it("does not treat a mismatched active connection as completed from the URL stage", async () => {
const connectionId = "22222222-2222-4222-8222-222222222222";
mockSearch.value = `source=clickup&stage=complete&resume=${connectionId}`;
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
listApplicationsMock.mockResolvedValueOnce({
applications: [{
id: "app-monday",
companyId: "company-1",
status: "active",
metadata: { sourceTemplateKey: "monday" },
}],
});
listConnectionsMock.mockResolvedValueOnce({
connections: [{
id: connectionId,
companyId: "company-1",
applicationId: "app-monday",
authKind: "oauth",
credentialPolicy: "per_user",
status: "active",
config: { sourceTemplateKey: "clickup" },
transportConfig: { sourceTemplateKey: "clickup" },
}],
});
await render();
await vi.waitFor(() => expect(container.textContent).toContain("This setup can’t be resumed"));
expect(mockNavigate).not.toHaveBeenCalledWith(`/apps/${connectionId}/permissions`, { replace: true });
expect(connectAppMock).not.toHaveBeenCalled();
expect(finishAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).not.toHaveBeenCalled();
});
it("rejects a matching-provider resume when the saved connection was already active", async () => {
const connectionId = "22222222-2222-4222-8222-222222222222";
mockSearch.value = `source=clickup&resume=${connectionId}`;
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
listApplicationsMock.mockResolvedValueOnce({
applications: [{
id: "app-clickup",
companyId: "company-1",
status: "active",
metadata: { sourceTemplateKey: "clickup" },
}],
});
listConnectionsMock.mockResolvedValueOnce({
connections: [{
id: connectionId,
companyId: "company-1",
applicationId: "app-clickup",
authKind: "oauth",
credentialPolicy: "per_user",
status: "active",
config: { sourceTemplateKey: "clickup" },
transportConfig: { sourceTemplateKey: "clickup" },
}],
});
await render();
await vi.waitFor(() => expect(container.textContent).toContain("This setup can’t be resumed"));
expect(connectAppMock).not.toHaveBeenCalled();
expect(finishAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).not.toHaveBeenCalled();
});
it("does not resolve a hidden provider when a resumed draft belongs to another provider", async () => {
const connectionId = "22222222-2222-4222-8222-222222222222";
mockSearch.value = `source=clickup&resume=${connectionId}`;
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
listApplicationsMock.mockResolvedValueOnce({
applications: [{
id: "app-monday",
status: "active",
metadata: { sourceTemplateKey: "monday" },
}],
});
listConnectionsMock.mockResolvedValueOnce({
connections: [{
id: connectionId,
applicationId: "app-monday",
authKind: "oauth",
credentialPolicy: "per_user",
status: "draft",
config: { sourceTemplateKey: "clickup" },
transportConfig: {},
}],
});
await render();
await vi.waitFor(() => {
expect(container.textContent).toContain("This setup can’t be resumed");
});
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).not.toHaveBeenCalled();
});
it("keeps an unavailable hidden draft on the blocked recovery screen", async () => {
const connectionId = "22222222-2222-4222-8222-222222222222";
mockSearch.value = `source=clickup&resume=${connectionId}`;
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
listApplicationsMock.mockResolvedValueOnce({ applications: [] });
listConnectionsMock.mockResolvedValueOnce({ connections: [] });
await render();
await vi.waitFor(() => {
expect(container.textContent).toContain("The saved connection no longer exists");
});
expect(mockNavigate).not.toHaveBeenCalled();
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).not.toHaveBeenCalled();
});
it("shows installation recovery for GitHub even when an advanced PAT method is available", async () => {
const connectionId = "22222222-2222-4222-8222-222222222222";
mockSearch.value = `source=github&resume=${connectionId}&oauth=failed&code=github_installation_required&installation_url=https%3A%2F%2Fgithub.com%2Fapps%2Fpaperclip-for-github%2Finstallations%2Fnew`;
+32
View File
@@ -1408,6 +1408,38 @@ describe("Connectors landing page", () => {
);
});
it("resumes a hidden provider only from its existing draft account row", async () => {
listApplicationsMock.mockResolvedValue({ applications: [application({
id: "app-clickup",
name: "ClickUp",
applicationKey: "app-gallery:clickup:one",
metadata: { sourceTemplateKey: "clickup" },
})] });
listConnectionsMock.mockResolvedValue({ connections: [connection({
id: "conn-clickup-draft",
applicationId: "app-clickup",
name: "ClickUp",
status: "draft",
config: { sourceTemplateKey: "clickup" },
})] });
await renderBrowse();
const row = container.querySelector('[data-app-slug="clickup"]');
expect(row).not.toBeNull();
expect(row?.textContent).toContain("Setup incomplete");
const finish = Array.from(row!.querySelectorAll("button")).find(
(button) => button.textContent === "Finish setup",
);
await act(async () => {
finish?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith(
"/apps/connect?source=clickup&resume=conn-clickup-draft",
);
expect(getAppStoreDefinition("clickup")).toBeNull();
});
it("filters the single list without restoring section chrome", async () => {
await renderBrowse();
+11
View File
@@ -6,6 +6,7 @@ import { AggregatorAppManager } from "./AggregatorAppManager";
import {
aiConnectionRouterPluginKey,
connectionSetupVerbForApp,
getConnectableAppDefinition,
isRetiredComposioConnection,
RETIRED_COMPOSIO_MESSAGE,
} from "@paperclipai/shared";
@@ -315,6 +316,16 @@ function accountActionHref(
if (connection.status === "draft" && row.entry) {
return appSourceResumeHref(row.slug, connection.id);
}
// Hidden curated definitions are not included in the gallery, but their
// already-saved drafts still need the same exact-provider resume flow.
// Keep fresh setup hidden by only using this fallback for an existing draft
// whose company row and saved connection identify the same known provider.
if (connection.status === "draft") {
const sourceSlug = appConnectionSourceSlug(connection);
if (sourceSlug && sourceSlug === row.slug && getConnectableAppDefinition(sourceSlug)) {
return appSourceResumeHref(sourceSlug, connection.id);
}
}
return `/apps/${connection.id}/permissions`;
}
+8 -1
View File
@@ -29,7 +29,7 @@ describe("app connect policy", () => {
expect(canEnterAppsConnect(new URLSearchParams("source=jira"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=asana"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=github"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=context7"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("source=context7"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=zapier"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=unknown"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("source=model-provider"))).toBe(false);
@@ -56,6 +56,13 @@ describe("app connect policy", () => {
expect(canEnterAppsConnect(new URLSearchParams("source=unknown&reconnect=connection-1"))).toBe(false);
});
it("admits hidden providers only for known-provider retained setup routes", () => {
expect(canEnterAppsConnect(new URLSearchParams("source=monday"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("source=monday&resume=connection-1"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=monday&reconnect=connection-1"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=unknown&resume=connection-1"))).toBe(false);
});
it("builds a generic source deep link", () => {
expect(appSourceConnectHref("notion")).toBe("/apps/connect?source=notion");
expect(appSourceConnectHref("notion", "intent-1"))
+6 -2
View File
@@ -81,7 +81,11 @@ export function canEnterAppsConnect(
) return false;
// A retained connection may belong to a provider hidden from fresh catalog
// setup. Admit only known providers here; the setup flow then proves the
// exact reconnect target is visible to the selected company before rendering.
if (getConnectableAppDefinition(source) && searchParams.get("reconnect")?.trim()) return true;
// exact reconnect or incomplete-draft target belongs to the selected company
// and matches this provider before resolving the hidden definition.
if (
getConnectableAppDefinition(source)
&& (searchParams.get("reconnect")?.trim() || searchParams.get("resume")?.trim())
) return true;
return source === "agentmail" || chatConnectorsEnabled ? appSupportsCatalogSetup(entry) : appSupportsToolCatalogSetup(entry);
}