From e8c8ba3c19c7e0fbd5ae138cb28684e283c04e36 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:53:11 -0500 Subject: [PATCH] feat(apps): add experimental MCP aggregator connectors (#13755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Its tool gateway applies company access rules and approval controls to connected apps. > - MCP aggregators expose many apps through one provider endpoint. > - Each aggregator needs its own credential, catalog, grants, and lifecycle in Paperclip. > - This pull request adds independent Zapier, Arcade, Composio Connect, and Executor setup with a common Access → Connect layout. > - A default-off MCP aggregators flag lets operators opt in while we complete provider acceptance tests. > - Agents use the normal Paperclip permissions, Test screen, and gateway after setup. ## Linked Issues or Issue Description **Subsystem affected** Apps, connection setup, shared contracts, and the remote MCP gateway. **Problem or motivation** Aggregator endpoints need clear provider setup and correct MCP sessions. Generic setup does not explain each provider's authentication or broad execution tools. Provider approval must preserve the original execution instead of replaying a write. **Proposed solution** Add four separate connectors behind Settings → Experimental → MCP aggregators. Start with human and agent access, then connect the endpoint and read its tools. Enable tools by default. Use the existing Permissions and Test screens after setup. Keep legacy Composio API-key and child connections intact. **Alternatives considered** A shared connection for all providers would mix credentials and access rules. Separate provider-specific permission and test screens would duplicate existing controls. Vercel Connect is outside this change. **Roadmap alignment** Extends the existing MCP Tool Gateway & Apps capability and the Connected Apps roadmap area. This work was requested and reviewed by the maintainer. Related work: #11894, #12630, #12632, #12634, and #12906 concern the legacy Composio broker. #13102 also covers remote MCP pagination. This change preserves the broker path and adds initialized sessions, response matching, and provider resume handling alongside pagination. ## What Changed - Add branded setup and interactive Storybooks for Zapier, Arcade, Composio Connect, and Executor. Use the existing access controls and normal action tests. Do not request a connection name or action choices during setup. - Add the default-off `enableMcpAggregators` flag to settings, managed feature metadata, the catalog, and setup guards. Hidden connections keep running. Legacy Composio connections remain unchanged. - Reuse the vault, grants, policy, and catalog models. Support OAuth discovery, bearer tokens, custom headers, and credential-bearing URLs. Add no database tables or migrations. - Initialize and retain Streamable HTTP sessions by connection and effective credentials. Read paginated catalogs and match streaming responses to request IDs. - Classify unfamiliar aggregator tools as writes despite upstream read-only hints; only exact reviewed read capabilities enter the read-only allowlist. Legacy Composio child behavior is preserved. - Preserve provider authorization links and execution IDs. Support Executor approve/resume, decline, and cancel without automatic replay of uncertain writes. - Preserve Off and Ask first choices during refresh and reconnect. Allow new tools and retire removed tools. Keep agent access updates atomic and preserve an empty agent selection. - Document connector UX rules, provider branding sources, and live acceptance results. - Stabilize the existing Sentry release fixture after its repeated CI failure by reusing one module mock; production Sentry behavior is unchanged. ## Verification - Final head `d11781970`: [CI run](https://github.com/paperclipai/paperclip/actions/runs/35633534900) passed, including broad typecheck, test shards, build, and E2E. All 54 checks pass; 2 optional checks are skipped. Greptile is 5/5, Security Scan passes, and all review threads are resolved. - Passed 27 focused connector Vitest checks and 18 connector-only Storybook browser checks before the flag change. All 85 stories rendered at desktop and narrow widths. - Passed 5 connector lifecycle/server checks and 7 selected flag checks after adding the flag. The latter cover settings, managed defaults, cached catalog visibility, and all four setup routes. - Review fixes passed 13 risk/handoff/lifecycle checks, dedicated session-expiration and transport regressions, 13 selected connector/gateway CI cases, and 10 selected setup/reconnect UI cases. A real Composio connection-list call also succeeded through the refreshed UI on `9ab115f71`. - UI and server TypeScript checks passed. UI build, Storybook build, token gates, and diff whitespace checks passed during implementation. - Real browser and real Paperclip agent tests passed for Arcade, Composio, and Executor. Tested action permissions, denied agent access, reconnect, disconnect, and isolation. Tested Arcade catalog additions/removal and Executor provider approve/resume, decline, and cancel. - Zapier live acceptance is incomplete. Its dedicated provider server is configured, but its credential-copy dialog returned an empty clipboard through browser automation. No live Zapier action is claimed. - The three isolated Sentry release cases pass after the CI fixture fix. - Local verification is deliberately narrow at the maintainer's request. The full local suite, recursive typecheck, and repository-wide build were not run. CI provides the broader checks. ## Risks - Shared MCP transport changes affect other remote MCP servers. Protocol fixtures cover initialized sessions, streaming response matching, pagination, and isolation. - Broad execution tools remain broad permissions. The provider governs actions inside those tools. - Provider handoff links are retained briefly in memory. After a server restart, a one-time link may require reopening the provider dashboard. Paperclip does not replay the original call. - Zapier remains unproven live. Custom-header imports and self-hosted endpoints have fixture coverage rather than a separate live account for every variant. - Turning the experimental flag off hides setup; it does not revoke existing credentials or stop existing connections. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, shell execution, and browser automation. The exact runtime model ID and context-window size are not exposed in this session. A separate Anthropic-backed Paperclip agent performed live gateway acceptance tasks. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/connections/CONNECTOR-PLAYBOOK.md | 25 +- doc/connections/REMOTE-MCP-BRAND-SOURCES.md | 16 ++ doc/connections/REMOTE-MCP-LIVE-ACCEPTANCE.md | 76 ++++++ doc/design/COMPONENT-INVENTORY.md | 14 + .../2026-09-21-independent-mcp-connectors.md | 220 +++++++++++++++ .../adapter-utils/src/command-redaction.ts | 18 +- .../shared/src/app-definitions-url.test.ts | 3 +- .../shared/src/app-definitions.generated.ts | 144 +++++----- packages/shared/src/app-definitions.test.ts | 3 +- packages/shared/src/app-definitions.ts | 3 +- .../shared/src/app-definitions/arcade.json | 36 +++ .../shared/src/app-definitions/composio.json | 26 +- .../shared/src/app-definitions/executor.json | 36 +++ packages/shared/src/feature-catalog.ts | 8 + packages/shared/src/index.ts | 3 + .../shared/src/remote-mcp-connectors.test.ts | 38 +++ packages/shared/src/remote-mcp-connectors.ts | 15 ++ packages/shared/src/types/index.ts | 1 + packages/shared/src/types/instance.ts | 2 + packages/shared/src/types/tool-access.ts | 14 + packages/shared/src/validators/instance.ts | 1 + packages/shared/src/validators/tool-access.ts | 10 +- scripts/ingest-app-definitions.mjs | 20 +- .../__tests__/generic-mcp-connection.test.ts | 8 +- .../instance-settings-service.test.ts | 1 + .../__tests__/remote-mcp-connectors.test.ts | 251 ++++++++++++++++++ .../src/__tests__/remote-mcp-pending.test.ts | 59 ++++ .../src/__tests__/remote-mcp-protocol.test.ts | 79 ++++++ .../__tests__/remote-mcp-redaction.test.ts | 18 ++ server/src/__tests__/sentry.test.ts | 10 +- .../src/__tests__/tool-access-service.test.ts | 32 +-- .../__tests__/tool-gateway-service.test.ts | 8 +- server/src/__tests__/tool-gateway.test.ts | 10 +- server/src/redaction.ts | 4 +- server/src/routes/tool-access.ts | 5 +- server/src/services/instance-settings.ts | 2 + server/src/services/mcp-http.ts | 115 +++++++- server/src/services/remote-mcp-pending.ts | 78 ++++++ server/src/services/tool-access-policy.ts | 8 + server/src/services/tool-access.ts | 232 ++++++++++++---- server/src/services/tool-gateway.ts | 106 +++++++- .../remote-mcp-connections.config.ts | 8 + .../remote-mcp-connections.spec.ts | 200 ++++++++++++++ ui/public/brands/apps/arcade.png | Bin 0 -> 63711 bytes ui/public/brands/apps/executor.png | Bin 0 -> 7256 bytes ui/public/brands/apps/manifest.json | 14 +- .../JsonSchemaForm.remote-mcp.test.tsx | 42 +++ ui/src/components/JsonSchemaForm.tsx | 53 +++- ui/src/components/SetupWizard.tsx | 4 +- .../connections/ConnectionSetupFlow.tsx | 50 ++-- .../remote-mcp/RemoteMcpConnectionSetup.tsx | 136 ++++++++++ .../remote-mcp/RemoteMcpDesignExample.tsx | 30 +++ .../remote-mcp/RemoteMcpManagement.tsx | 40 +++ .../remote-mcp/RemoteMcpProductionSetup.tsx | 133 ++++++++++ .../connections/remote-mcp/providers.ts | 59 ++++ .../features/connections/remote-mcp/types.ts | 42 +++ ui/src/hooks/useMcpAggregatorsEnabled.ts | 12 + ui/src/pages/DesignGuide.tsx | 5 + .../InstanceExperimentalSettings.test.tsx | 14 + ui/src/pages/InstanceExperimentalSettings.tsx | 13 + ui/src/pages/apps/AppDetail.tsx | 38 ++- ui/src/pages/apps/AppsConnect.test.tsx | 31 ++- ui/src/pages/apps/AppsConnect.tsx | 26 ++ ui/src/pages/apps/Browse.test.tsx | 19 ++ ui/src/pages/apps/Browse.tsx | 4 + .../apps/app-detail/PermissionsPanel.tsx | 2 +- ui/src/pages/apps/app-detail/TestPanel.tsx | 93 ++++++- ui/src/pages/apps/composio-services.ts | 2 +- ui/storybook/fixtures/remoteMcpConnections.ts | 98 +++++++ .../prototypes/RemoteMcpConnectionReview.tsx | 143 ++++++++++ .../stories/ArcadeConnection.stories.tsx | 42 +++ .../stories/ComposioConnection.stories.tsx | 43 +++ .../stories/ExecutorConnection.stories.tsx | 42 +++ .../stories/ZapierConnection.stories.tsx | 32 +++ 74 files changed, 2988 insertions(+), 240 deletions(-) create mode 100644 doc/connections/REMOTE-MCP-BRAND-SOURCES.md create mode 100644 doc/connections/REMOTE-MCP-LIVE-ACCEPTANCE.md create mode 100644 doc/plans/2026-09-21-independent-mcp-connectors.md create mode 100644 packages/shared/src/app-definitions/arcade.json create mode 100644 packages/shared/src/app-definitions/executor.json create mode 100644 packages/shared/src/remote-mcp-connectors.test.ts create mode 100644 packages/shared/src/remote-mcp-connectors.ts create mode 100644 server/src/__tests__/remote-mcp-connectors.test.ts create mode 100644 server/src/__tests__/remote-mcp-pending.test.ts create mode 100644 server/src/__tests__/remote-mcp-protocol.test.ts create mode 100644 server/src/__tests__/remote-mcp-redaction.test.ts create mode 100644 server/src/services/remote-mcp-pending.ts create mode 100644 tests/storybook-visual/remote-mcp-connections.config.ts create mode 100644 tests/storybook-visual/remote-mcp-connections.spec.ts create mode 100644 ui/public/brands/apps/arcade.png create mode 100644 ui/public/brands/apps/executor.png create mode 100644 ui/src/components/JsonSchemaForm.remote-mcp.test.tsx create mode 100644 ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx create mode 100644 ui/src/features/connections/remote-mcp/RemoteMcpDesignExample.tsx create mode 100644 ui/src/features/connections/remote-mcp/RemoteMcpManagement.tsx create mode 100644 ui/src/features/connections/remote-mcp/RemoteMcpProductionSetup.tsx create mode 100644 ui/src/features/connections/remote-mcp/providers.ts create mode 100644 ui/src/features/connections/remote-mcp/types.ts create mode 100644 ui/src/hooks/useMcpAggregatorsEnabled.ts create mode 100644 ui/storybook/fixtures/remoteMcpConnections.ts create mode 100644 ui/storybook/prototypes/RemoteMcpConnectionReview.tsx create mode 100644 ui/storybook/stories/ArcadeConnection.stories.tsx create mode 100644 ui/storybook/stories/ComposioConnection.stories.tsx create mode 100644 ui/storybook/stories/ExecutorConnection.stories.tsx create mode 100644 ui/storybook/stories/ZapierConnection.stories.tsx diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index 07651e601a..8b786adc98 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -438,6 +438,26 @@ Avoid separate methods for: The default setup screen should ask only for information required to make the connection work or enforce a real tenant boundary. Follow these rules: +- Do not ask for a Paperclip **Connection name** during setup. Derive the display + name from the provider and observed account/workspace identity. A provider-required + app/bot name is a separate configuration requirement, not a connection label. +- Use the traditional Gmail/Google Docs setup structure: a compact horizontal + progress header and **Access → Connect**, without a numbered sidebar, + tool-permissions step or optional Test step. Authentication and successfully + reading the tool catalog are enough to complete setup. Do not require a sample + action or add an empty-catalog onboarding detour. Reuse the existing access screen: + “Which humans can use this credential?” and “Which agents can use this connection?” +- After setup, use the regular connection **Permissions** screen: reuse its + canonical searchable Actions list, Read/Write filters, Off / Ask first / Allowed + controls and per-action Test dialog. Do not build a parallel permissions list or + provider-specific testing page. Discovery errors stay inline on Connect; an empty + returned catalog belongs to the ordinary saved-connection state. +- Enable every discovered tool automatically. Put later Allowed / Ask first / Off + controls in management, separate from connection access. Reconnect and refresh + retain existing restrictions; new tools are Allowed under the existing access rules. +- Show browser sign-in/pending/return only for a real OAuth handoff supported by + the chosen authentication. Zapier's pasted MCP URL or bearer token requires no + Paperclip sign-in window. Advanced token/header setups need no invented OAuth step. - Put optional narrowing in fields marked `advanced: true`. - Give hidden fields a `defaultValue`; never create a hidden required field the server cannot fill. @@ -841,8 +861,9 @@ At minimum, add or update tests in these layers: declared. - Finish setup resumes the exact draft using `resumeConnectionId`. - Optional customer OAuth details stay folded when automatic OAuth exists. -- Setup success leads to the connection's Test page, or to Permissions when a - separate agent-resource assignment is the next step. Follow the +- Successful authentication and tool discovery finish setup and lead to the + connection's regular Permissions screen. Testing is available there through each + action's Test button; it is never an onboarding step. Follow the [connection UX guidance](#connection-ux-and-user-journeys). - Interactive Storybooks cover setup and ongoing task interactions, including relevant failure states; the walkthrough matches the implemented journey. diff --git a/doc/connections/REMOTE-MCP-BRAND-SOURCES.md b/doc/connections/REMOTE-MCP-BRAND-SOURCES.md new file mode 100644 index 0000000000..7b7ee7d1eb --- /dev/null +++ b/doc/connections/REMOTE-MCP-BRAND-SOURCES.md @@ -0,0 +1,16 @@ +# MCP connector brand assets + +Retrieved 2026-09-21 from the providers’ own sites for the independent connector +design review. Brand-library membership does not publish a catalog connector. +The four connectors are available for setup only when the MCP aggregators experimental flag is enabled. +Zapier and Composio reuse the existing reviewed local marks. + +| File | Source | SHA-256 | +| --- | --- | --- | +| `ui/public/brands/apps/arcade.png` | [Arcade site touch icon](https://www.arcade.dev/_astro/webclip.BYnZsC5R.png), linked from [arcade.dev](https://www.arcade.dev/) | `5b3704e210b6dc70fffab260d5416624f37db19108399a6d2c18c848d33465ad` | +| `ui/public/brands/apps/executor.png` | [Executor site icon](https://executor.sh/favicon-192.png), linked from [executor.sh](https://executor.sh/) | `e5fd761a0cd80d51d451cc06e9e6d0236dcc39317248a13ac5db5fcf4d52d0ca` | + +Both files preserve provider artwork without recoloring. The shared AppLogo +provides the standard gray frame and contained padding. Arcade’s SVG favicon +wrapped embedded raster content; the 180px touch icon linked by the same official +page was used to satisfy the repository’s artwork safety checks. diff --git a/doc/connections/REMOTE-MCP-LIVE-ACCEPTANCE.md b/doc/connections/REMOTE-MCP-LIVE-ACCEPTANCE.md new file mode 100644 index 0000000000..cf201584c7 --- /dev/null +++ b/doc/connections/REMOTE-MCP-LIVE-ACCEPTANCE.md @@ -0,0 +1,76 @@ +# Independent MCP connectors — acceptance, 2026-09-21 + +Test environment: an isolated development worktree with a fresh database and organization. No production data was cloned. The API served the compiled UI with browser-reachable OAuth callbacks. Detailed account and local-instance evidence remains in a private acceptance report. + +**This PR delivers default-off experimental support at the maintainer's request. General provider acceptance remains incomplete: Zapier needs its generated credential entered before live validation can finish.** Three providers have real browser and real agent proof. Simulations are recorded separately below. The maintainer's subsequent delivery instruction was to put these connectors behind an experimental MCP aggregators flag and open a PR with passing checks. The Zapier gap is an explicit limitation of that experimental scope, not a claim that the connector playbook's full provider acceptance is complete. Complete that acceptance before promoting the feature out of experimental status. + +## Experimental rollout + +Setup is behind **Settings → Experimental → MCP aggregators** (`enableMcpAggregators`). It defaults off on self-hosted and managed instances. When off, all four fresh catalog entries are hidden and direct setup, reconnect setup, and OAuth-start requests are rejected server-side. Existing connections keep running and remain available for management. Legacy Composio API-key/child connections are unchanged. An in-progress OAuth callback may complete; the flag does not revoke already issued credentials or grants. + +Focused regression tests cover flag defaults, persistence, managed metadata, cached catalog visibility, all four direct setup routes, and server-side rejection before network/credential writes. + +## Live results + +| Provider | Functional correctness | UX readiness | Observed account, catalog, and actual results | +| --- | --- | --- | --- | +| Arcade | Passed OAuth setup, Test, real agent, Off, Ask first, denied agent, catalog additions/removal, reconnect, disconnect, and restoration. | Ready for the tested gateway OAuth flow after the fixes below. | Dedicated test gateway with a verified GitHub account. Started with 4 exposed tools, added two read actions, then removed one (5 remain). `Github.GetRepository` returned `paperclipai/paperclip`, branch `master`, repository ID `1170821064`. | +| Composio | Passed default endpoint OAuth, Test, real agent, Off, Ask first, denied agent, refresh, reconnect, disconnect, and restoration. | Ready for Composio Connect. Optional GitHub app consent remains at GitHub's user-verification screen; this does not block the verified DeepWiki action. | Verified provider account; 11 meta tools. Search discovered `DEEPWIKI_MCP_READ_WIKI_STRUCTURE`; Multi Execute returned the real Paperclip documentation hierarchy, 1 success / 0 errors. The real agent repeated discovery and execution. | +| Executor | Passed workspace OAuth, Test, real agent, Off, Ask first, denied agent, refresh, reconnect, disconnect, and restoration. Provider approve/resume, decline, cancel, and Paperclip approval → provider approval were exercised. | Ready for the tested hosted workspace with model-side resume. Decline/cancel copy now describes the deliberate outcome rather than suggesting a retry. | Dedicated test workspace with a verified provider account; 7 tools. Execution returned integration slugs `executor`, `context7`. A disposable Context7 read paused for approval; resuming the same execution returned real React library IDs. | +| Zapier | Live proof blocked after provider setup; production integration and deterministic tests are implemented. | Approved setup design and Storybook checks pass. Real URL-paste workflow still needs completion. | Created a dedicated Managed-mode server with Google Sheets Find Spreadsheet / Get Spreadsheet by ID, using an existing test account. Its generated credential is masked. Copy buttons returned an empty clipboard through automation. No Paperclip credential has been entered or live tool call made. | + +The real Paperclip agent used a vaulted model credential. No secret value is stored in this report or the source tree. + +### Agent evidence + +- Gateway acceptance task: six real gateway calls; Arcade repository read and Executor skills → search → integrations list. +- Execution and disabled-tool task: Composio discovery and DeepWiki execution returned actual headings including Overview, Core Concepts, Getting Started, Server Architecture, and User Interface. Arcade's Off tool was absent from callable discovery. Executor public helper paths were readable and executable. +- Revocation and access-denial task: disconnected Arcade exposed no tools; ungranted Composio exposed no tools and reported that identity/access needed review. Executor remained available and returned its actual integration list. This verifies cross-connection isolation through a real agent. + +### Governance and lifecycle evidence + +- Arcade CountStargazers Off prevented a Test call and disappeared from the real agent's tool surface. GetRepository Ask first made no call until “Allow once”; the saved Test result then showed the actual repository response. +- Composio Multi Execute Off prevented Test execution. Search Ask first completed only after Paperclip review. Removing all agent access made Search Off despite its saved Ask first choice. +- Executor skills Off prevented testing. Removing agent access also prevented execute despite an Ask first choice. A separately allowed execution paused at the provider, preserving its execution ID. Inline acceptance resumed that ID; decline and cancel each stopped their respective execution. Paperclip Ask first → Allow once → provider pending survived navigation to Review and back. +- Arcade catalog refresh enabled newly added GetFileContents and GetIssue automatically, preserved CountStargazers Off and GetRepository Ask first, and removed GetIssue after its removal upstream. Composio/Executor refreshes retained their stable catalogs and rules. Provider-controlled additions to Composio's meta catalog were not manufactured; changed-catalog fixtures cover the common path. +- All three OAuth reconnects retained an empty agent selection and existing Off/Ask first choices. Reloaded screens agreed with effective Test access. +- Disconnected each through the real UI. Subsequent requests for previously valid Test actions returned HTTP 404 `tool_not_found` for all three. Reconnected through Apps and verified fresh Arcade repository, Composio search, and Executor skills calls. The three connections are left connected with default permissions for review. +- Removed the temporary Executor `context7.*` approval policy after testing. The dedicated test gateway, Zapier server, and Context7 connection remain available for review. Existing unrelated provider configurations were not changed. + +## Defects found, fixed, and retested + +1. Enabled DCR ownership for direct MCP OAuth; Composio no longer incorrectly requires a configured client ID. +2. Added explicit provider authorization/approval states, validated handoff links, execution identifiers, and resume controls. Executor's `resume.content` must be a JSON string; corrected it after an observed provider rejection and retested successfully. +3. Added an object JSON editor for open-ended schemas. Composio's nested `arguments` object was otherwise impossible to enter. The real Multi Execute call passed afterward. +4. Corrected broad execution risk classification and the false JWT redaction of three exact public Executor helper selectors. Actual secrets, bearer assignments, and arbitrary dotted values retain redaction. The execution acceptance task verified the selectors live. +5. Prevented app-generated Ask first policies from granting access to an ungranted agent. Negative fixture and live Test/agent checks pass. +6. Allowed an empty selected-agent list and made installation reach plus permission binding updates atomic. OAuth completion no longer substitutes all agents for an empty saved selection. Real reconnects and a dedicated OAuth callback regression pass. +7. Refreshed permission caches with the catalog so newly added tools display Allowed immediately. +8. Retired disappeared tools in stored discovery, not merely the refresh response. Reappearing tools keep existing restrictions. The regression checks persisted database state; the Arcade removal was retested live. +9. Prevented retries of an already-approved provider-pending runtime call from starting another execution. The fixture verifies one dispatch and retained execution identity. Test requires an explicit Reset before starting another approval-controlled call. +10. Added production Reconnect, Manage in provider, and Disconnect controls using the same management component as Storybook. Saved access loading and setup failures remain actionable. + +## Supporting checks + +All newly added isolated checks passed, with one test worker: + +- 27 Vitest checks: protocol (7), provider pending (5), connector lifecycle/governance/OAuth (4), redaction (2), shared contracts (7), schema form (2). +- 18 connector-only Storybook browser checks: four complete setup journeys, four draft/auth journeys, desktop/narrow state matrices, keyboard recovery, Zapier's absence of OAuth, normal Test states, and Executor approve/decline/cancel. +- All 85 connector stories rendered at 1280px/dark and 390px/light without page errors or horizontal overflow. Inspected generated setup and Test screenshots. Evidence is under `tests/storybook-visual/test-results/remote-mcp/` (ignored local test output). +- UI and server direct TypeScript checks, token gates, UI build, Storybook build, and `git diff --check` passed. +- **No full repository test suite, recursive typecheck, or repository-wide build was run locally**, following the maintainer's explicit resource constraint. The PR's CI runs the required broad typecheck, test shards, and build; those gates must pass before handoff. Narrow local verification is not a waiver of CI. No schema migration or lockfile change is included. + +Storybook links: `apps-connections-zapier--complete-setup-journey`, `apps-connections-arcade--complete-setup-journey`, `apps-connections-composio--complete-setup-journey`, `apps-connections-executor--complete-setup-journey`. `apps-connections-executor--provider-handoff-after-setup` uses mocked responses and makes no real authorization request. + +## Remaining work and limits + +PR review regressions: expired MCP sessions now trigger one safe discovery handshake; action calls fail visibly and wait for an explicit retry. Provider handoff detection recognizes protocol/provider envelopes rather than arbitrary app-data statuses. Buffered transports preserve response-ID matching, size limits, and distinct error codes. Dedicated regression checks and selected existing connector/gateway cases pass. Ordinary connector resume/reconnect continues through its existing controller. + +Aggregator action risk is conservative: an unfamiliar or renamed tool defaults to write risk even if the provider advertises it as read-only. Only an explicit reviewed allowlist receives read risk; annotations can still escalate it. This affects risk labels and risk-based governance, not the approved default-enabled behavior or saved Off/Ask first choices. Legacy Composio child connections retain their existing classification path. + +- Paste Zapier's generated Full URL into the already-open local Zapier setup field (not chat), then complete its browser Test, agent, governance, refresh, and lifecycle acceptance. Do not rotate the displayed credential unnecessarily. +- Optional Composio GitHub account authorization awaits the user's GitHub verification. The no-auth DeepWiki app path is proven; GitHub app execution is not claimed. +- Hosted OAuth paths were tested live. Custom-header/session imports, credential-bearing URLs, protocol URL elicitation, pagination edge cases, and revocation races have deterministic fixture coverage rather than separate live accounts/endpoints for every variant. +- Provider auth links are kept briefly in memory; durable records retain redacted handoff/execution identifiers. After an application restart, a one-time auth link may need reopening from the provider dashboard. Calls are never automatically replayed to recover it. + +Completion still requires observed live results for Zapier. Passing stories and fixtures do not substitute for that proof. diff --git a/doc/design/COMPONENT-INVENTORY.md b/doc/design/COMPONENT-INVENTORY.md index 84bdccd4ab..91bf50d192 100644 --- a/doc/design/COMPONENT-INVENTORY.md +++ b/doc/design/COMPONENT-INVENTORY.md @@ -6,6 +6,20 @@ Run scope: `ui/src/components/` and `ui/src/pages/` on branch `design/token-extr ## Counts +### Independent MCP connection setup — 2026-09-21 + +`ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx` is the controlled +Access → Connect and management composition for Zapier, Arcade, +Composio and Executor. It reuses Gmail’s StepHeader and AccessStepContent, plus SetupWizardFooter, +AppLogo, InlineBanner and existing form/dialog primitives. Saved connections reuse +the canonical PermissionsPanel ActionsSection and its ActionTestDialog; there is +no separate setup test or permission list. Each +provider has an independent state and controller. It does not initiate network +authentication requests or persist credentials. The shared Test dialog uses normal +API calls, intercepted by scoped in-memory Storybook fixtures. The first milestone is design review; production +routes do not use it yet. See `/design-guide` and Storybook **Apps / Connections** +for the provider variants and complete interactive state matrix. + Execution recovery reuses the existing transcript header and task status. Routine phases add no list badges, status cards, or reconciliation dialogs. Only a transient reconnection changes the header text. Automatic recovery decisions remain in the local run log. Storybook **Tasks / Execution recovery** demonstrates quiet task lists, native and legacy transcript headers, and dashboard composition. | Area | Count | diff --git a/doc/plans/2026-09-21-independent-mcp-connectors.md b/doc/plans/2026-09-21-independent-mcp-connectors.md new file mode 100644 index 0000000000..c9103bdbc0 --- /dev/null +++ b/doc/plans/2026-09-21-independent-mcp-connectors.md @@ -0,0 +1,220 @@ +# Four independent MCP connectors + +Branch: `codex/unified-mcp-connectors` +Worktree: dedicated checkout on `codex/unified-mcp-connectors` +Base: `b19307758` + +Zapier, Arcade, Composio and Executor each have their own connection, credentials, +catalog, agent access, permissions, sessions and lifecycle. Their setup shares the +same structure: **Access → Connect**. Underlying apps remain managed +in the provider; Paperclip does not create child connections for every app. + +## Delivery checkpoints + +- [x] Create the fresh worktree. +- [x] Build controlled application views and four interactive Storybook groups. +- [x] Review the designs with the user and incorporate revisions. +- [x] Wire the reviewed views to production APIs and implement shared protocol support. +- [ ] Conduct real browser acceptance testing for all four providers, fix and retest. +- [x] Complete the user-authorized narrow checks: new isolated tests, direct UI/server typechecks, token gates, UI and Storybook builds. Full repository suites/builds were explicitly excluded. + +Design review is complete and the user authorized production integration and live browser testing. The implementation is running in fresh local data. Three providers have real browser and agent proof; Zapier is waiting for a credential handoff. See [the current acceptance report](../connections/REMOTE-MCP-LIVE-ACCEPTANCE.md). Simulations are not live proof. + +## Review links and operation + +The review server uses the build from this worktree on port 6137: + +| Provider | Complete journey | +| --- | --- | +| Zapier | `apps-connections-zapier--complete-setup-journey` | +| Arcade | `apps-connections-arcade--complete-setup-journey` | +| Composio | `apps-connections-composio--complete-setup-journey` | +| Executor | `apps-connections-executor--complete-setup-journey` | + +Choose who can use the connection, continue, then use **Use example configuration**. The separate +**Storybook simulation** area supplies provider responses and browser sign-in or +completion events. After connection, Test opens the regular per-action dialog with +scoped mock API responses; approval requests stay pending in this preview. Provider links are intercepted by this simulator; +they do not open real authorization pages. Example catalogs and schemas are +representative, not production constants. Never enter real credentials here. + +Each provider group exposes initial access, selected agents, URL and advanced +setup, connecting, invalid URL, rejected credentials, unreachable endpoint, +and post-setup permissions/management states. Successful authentication and catalog +discovery complete setup. There is no empty-catalog or Test setup step. Tool restrictions +and action tests use the regular Permissions screen after setup. All new tools are enabled automatically. + +Browser sign-in pending/return/cancel stories apply only to Arcade, Composio and +Executor OAuth configurations. Zapier uses the pasted URL/token without an OAuth +handoff. Upstream app authorization and Executor resume now use the shared +post-setup Test dialog; bespoke setup test stories +have been removed. Narrow access, connection details +and tool management are directly accessible. + +### Design feedback incorporated — 2026-09-21 + +- Reuse the actual Gmail `AccessStepContent` and `StepHeader`: human credential + sharing and agent reach come before credentials, with compact top progress bars. +- Remove Connection name, the numbered sidebar and setup tool-permission controls. +- Finish setup as soon as authentication and tool discovery succeed, with all + tools enabled. Remove the empty-catalog story and every setup Test step. +- Reuse the actual Permissions `ActionsSection` and `ActionTestDialog` after setup, + including search, Read/Write filters, permission radios and per-action testing. +- Keep tool restrictions in management, separate from who can use the connection. +- Remove Zapier OAuth stories/options and unsupported provider resume examples. +- Update the canonical connector playbook and local `chat-connector-ux` skill with + these conventions, including the explicit prohibition on setup connection names. + +Drafts live only in React state in the previews and clear on reload. The production +controller must persist progress through refresh and OAuth redirects using the +existing server draft and vault mechanisms. Do not copy preview persistence into +production. + +To rebuild and serve: + +```sh +pnpm --filter @paperclipai/ui build-storybook +node scripts/serve-storybook-static.mjs --port 6137 +``` + +## Provider setup contracts + +| Provider | New setup | Permission boundary | +| --- | --- | --- | +| Zapier | Paste generated secret-bearing URL; alternatively endpoint plus bearer token. | Recommend Managed mode for individual action controls. Agentic mode exposes discovery/execution tools; permissions cover the whole exposed call. | +| Arcade | Paste gateway URL and sign in through the gateway’s configured User Source. Advanced: bearer API key plus `Arcade-User-ID`. | Actual exposed gateway tools. Individual apps can require further authorization. | +| Composio | Prefill `https://connect.composio.dev/mcp`, then authenticate. Advanced: externally configured session URL and headers. | Connect’s discovery, execution, connection-management and sandbox tools. Direct-tools sessions can expose individual actions. | +| Executor | Paste hosted workspace URL or reachable self-hosted HTTP endpoint and authenticate. Advanced user API key when supported. | Execution and helper tools; action policies remain in Executor. Preserve execution and MCP session identity for resume. | + +The production catalog always comes from discovery, never the Storybook fixture. +Only expose a pending-state recovery action when the actual provider response and +protocol support it. Executor’s browser approval/resume is a specific supported +pattern; implement it in the shared post-setup Test dialog, not in onboarding. + +Existing Composio project-API-key and child connections must remain supported. +New direct-MCP setup must not migrate their credentials or grants. Narrow legacy +broker checks by setup/transport instead of treating all Composio connections as +parent brokers. Vercel Connect remains a separate follow-up: its documented role +supplies credentials for an app’s MCP connection rather than this aggregator flow. + +Research references (checked 2026-09-21): + +- Zapier [setup](https://docs.zapier.com/mcp/get-started/connect/other) and [tool modes](https://docs.zapier.com/mcp/overview/how-tools-work). +- Arcade [MCP gateways](https://docs.arcade.dev/en/operate/governance/mcp-gateways). +- Composio [Connect](https://docs.composio.dev/docs/composio-connect) and [sessions](https://docs.composio.dev/docs/sessions-via-mcp). +- Executor [MCP proxy](https://executor.sh/docs/mcp-proxy) and [implementation](https://github.com/UsefulSoftwareCo/executor). +- Vercel [AI SDK and MCP integration](https://vercel.com/docs/connect/frameworks/ai-sdk-and-mcp). + +## Production implementation scope + +The views live in `ui/src/features/connections/remote-mcp/`; fixtures and the +simulator live under `ui/storybook/`. Reuse the views in the production controller +and keep the stories synchronized. The production controller is routed into Apps. + +1. **Definitions and contracts:** give all four providers independent catalog + definitions, setup/branding metadata and capabilities. Permit branded setup + and configuration imports to use OAuth discovery, bearer tokens, custom + headers and credential-bearing URLs. Extend existing connection/test types + with provider presentation and upstream pending state. No new connection + model or database tables are planned. +2. **MCP transport:** complete initialized Streamable HTTP operation, paginated + `tools/list`, response matching by JSON-RPC ID, and session continuity across + execution/resume. Existing `server/src/services/mcp-http.ts` and tool-access + discovery need this work; a one-request proxy is insufficient. +3. **Gateway governance:** enforce company membership, agent grant and tool + permission on both discovery and invocation, including resume. Allow new + catalog entries under existing connection access; preserve explicit Off and + Ask first, remove disappeared tools, and audit changes. Broad-tool permission + covers the entire exposed call. +4. **Authentication and recovery:** keep credentials/catalogs/policies/sessions + isolated by connection and effective identity. Use the vault, endpoint + validation and secret redaction. Preserve execution IDs and handle provider + authorization URLs and URL elicitation in both runtime and Test. Never + automatically repeat a call when a write may already have happened. Disconnect + must revoke access and invalidate sessions without affecting other connections. +5. **UI controller:** connect these controlled views to `ConnectionSetupFlow`, + current permissions and Test interfaces, draft storage, OAuth and existing + service APIs. Resolve provider pending actions using observed capabilities. + Save errors remain visible and do not pretend to persist a draft. The test’s + acting agent uses the same effective rules as a real agent gateway call. +6. **Verification:** protocol/governance fixtures, Storybook interaction and + visual checks, token gates, Storybook build, then required repository typecheck, + tests and build. Run the final checks on the integrated production change. + +## Historical design milestone verification + +Review environment: macOS, this worktree, static Storybook, example company, +agents and catalogs. Browser UI simulation and production acceptance are recorded +separately. Screenshots generated by the browser suite are local test evidence; +they are not Linux visual-regression baselines. + +Commands: + +```sh +pnpm --filter @paperclipai/ui typecheck +pnpm check:token-gates +pnpm exec vitest run ui/src/components/SetupWizard.test.tsx ui/src/components/JsonSchemaForm.test.tsx ui/src/features/connections/ConnectionSetupFlow.architecture.test.ts ui/src/pages/apps/app-detail/TestPanel.test.tsx +pnpm --filter @paperclipai/ui build-storybook +pnpm exec playwright test --config tests/storybook-visual/remote-mcp-connections.config.ts +``` + +Observed fixes during review: Save & exit now explicitly avoids submitting its +form; schema inputs expose accessible names; setup reuses the Gmail access choices and compact top progress header; newly discovered fixture tools default to Allowed without resetting saved +restrictions. The shared components retain their existing behavior otherwise. + +### Design revision verification (before production integration) + +- UI typecheck, token gates, 34 focused tests and Storybook build passed. +- All 15 browser checks passed, including four complete Access → Connect journeys + that finish with zero action calls and every discovered action Allowed. +- The canonical Permissions action list and Test dialog cover effective agent + access, denied agents, disabled tools, success, errors and pending approval. + Permission changes survive catalog refresh and reconnect; disconnect blocks use. +- All 82 stories render at 1280px/dark and 390px/light without page errors or + horizontal overflow: Zapier 18, Arcade 21, Composio 22, Executor 21. +- Browser inspection confirmed Zapier goes straight from Connect to saved + permissions, then opens the shared Test dialog. Narrow Permissions screenshots + for Zapier/Composio and the shared Test dialog were visually reviewed. +- Fake credentials stay out of browser storage, and scoped test requests never + leave the Storybook mock. No real provider action ran. + +Screenshots are local test evidence in +`tests/storybook-visual/test-results/remote-mcp/`. Earlier 117/122-story results +included the removed setup Test flow and are superseded by this revision. + +The removed simulations were never live provider proof. Production results and the final narrow check commands are recorded in the acceptance report. The user subsequently prohibited running the full suite locally; that later constraint supersedes the earlier repository-wide verification plan. + +Dependency installation previously used the pinned pnpm after a base-branch +patch/lockfile mismatch (`postgres@3.4.9`); the tracked lockfile was restored. +No dependency or lockfile changes are included. + +## Current live acceptance + +See [REMOTE-MCP-LIVE-ACCEPTANCE.md](../connections/REMOTE-MCP-LIVE-ACCEPTANCE.md) for provider-specific results, actual agent task links, fixes, retests, and remaining gaps. + +- Arcade, Composio, and Executor: real OAuth, catalog, Test and agent calls observed; governance and lifecycle exercised. +- Zapier: dedicated provider server configured, but its generated secret URL still needs to be pasted into the local setup form. No live execution proof is claimed. +- Supporting checks: 27 newly added isolated Vitest checks, 18 connector-only Storybook checks, 85 stories at desktop/narrow widths, direct UI/server typechecks, token gates, UI build, and Storybook build passed. One test worker; no full repository suite or recursive build/typecheck. + +The isolated test instance has a fresh database and a browser-reachable OAuth callback. All three tested connections were restored after revocation checks and are available for review. + +For **each** provider, record environment, account identity, observed catalog, +journeys, actual results, useful screenshots, defects/fixes/retests and gaps: + +1. Discover it from Apps, connect it, and verify account and catalog. +2. Assign agent access and exercise Allowed, Ask first and Off. +3. Run a useful action in Test and verify its external result; use disposable + data for writes. +4. Have a real Paperclip agent call through the Paperclip gateway. Verify an + ungranted agent and disabled tool are denied. +5. Exercise applicable provider authorization/approval, including Executor resume + without starting a second execution. +6. Add/remove tools upstream and refresh. New tools become Allowed; prior Off and + Ask first remain. Confirm reload, draft return, reconnect and disconnect. +7. Change this connection and prove the other connections remain unaffected. +8. Assess redirects, transient errors, duplicate writes, copy, keyboard access, + desktop and narrow layouts. Fix defects and repeat affected journeys. + +Completion requires observed live results for **all four**, with functional +correctness and UX readiness assessed separately. Storybook successes do not +check any live acceptance box. diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 5890973961..d922367c1d 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -1,5 +1,16 @@ export const REDACTED_COMMAND_TEXT_VALUE = "***REDACTED***"; +// These exact public Executor helper addresses resemble dotted bearer tokens. +// Do not exempt arbitrary provider paths, prefixes, or user-defined selectors. +const PUBLIC_EXECUTOR_TOOL_SELECTORS = new Set([ + "executor.coreTools.integrations.list", + "executor.coreTools.connections.list", + "executor.coreTools.policies.list", +]); +export function isPublicExecutorToolSelector(value: string): boolean { + return PUBLIC_EXECUTOR_TOOL_SELECTORS.has(value); +} + const SECRET_NAME_PATTERN = String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)[A-Za-z0-9_-]*`; const COMMAND_CLI_SECRET_OPTION_RE = new RegExp( @@ -76,7 +87,12 @@ export function redactCommandText( ) .replace(COMMAND_OPENAI_KEY_RE, redactedValue) .replace(COMMAND_GITHUB_TOKEN_RE, redactedValue) - .replace(COMMAND_JWT_RE, redactedValue); + .replace(COMMAND_JWT_RE, (match, offset: number, source: string) => { + // The JWT heuristic may match only the first three segments; inspect the + // complete address so a longer secret sharing a prefix stays redacted. + const address = source.slice(offset).match(/^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*/)?.[0]; + return address && isPublicExecutorToolSelector(address) ? match : redactedValue; + }); } // A JSON secret field is a key/value pair such as `"token":"opaque-value"`. The diff --git a/packages/shared/src/app-definitions-url.test.ts b/packages/shared/src/app-definitions-url.test.ts index 5503545e72..f5bcea4768 100644 --- a/packages/shared/src/app-definitions-url.test.ts +++ b/packages/shared/src/app-definitions-url.test.ts @@ -33,9 +33,10 @@ describe("tool app gallery URL matching", () => { expect(getAppDefinitionForUrl("https://drivemcp.googleapis.com/mcp/v1")?.slug).toBe("google-drive"); }); - it("lists Composio as a connectable API-key app", () => { + it("lists Composio Connect alongside the legacy API-key method", () => { const composio = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "composio"); expect(composio?.methods).toEqual([ + expect.objectContaining({ key: "mcp", transport: "mcp_remote", ownershipModes: ["dcr", "customer"] }), expect.objectContaining({ key: "api-key", transport: "rest_api", auth: "api_key" }), ]); }); diff --git a/packages/shared/src/app-definitions.generated.ts b/packages/shared/src/app-definitions.generated.ts index 90aabc8775..1b8139bb8a 100644 --- a/packages/shared/src/app-definitions.generated.ts +++ b/packages/shared/src/app-definitions.generated.ts @@ -1,74 +1,76 @@ import a0 from "./app-definitions/agentmail.json" with { type: "json" }; import a1 from "./app-definitions/zapier.json" with { type: "json" }; -import a2 from "./app-definitions/railway.json" with { type: "json" }; -import a3 from "./app-definitions/github.json" with { type: "json" }; -import a4 from "./app-definitions/slack.json" with { type: "json" }; -import a5 from "./app-definitions/microsoft-teams.json" with { type: "json" }; -import a6 from "./app-definitions/imessage-photon.json" with { type: "json" }; -import a7 from "./app-definitions/telegram.json" with { type: "json" }; -import a8 from "./app-definitions/discord.json" with { type: "json" }; -import a9 from "./app-definitions/notion.json" with { type: "json" }; -import a10 from "./app-definitions/posthog.json" with { type: "json" }; -import a11 from "./app-definitions/linear.json" with { type: "json" }; -import a12 from "./app-definitions/context7.json" with { type: "json" }; -import a13 from "./app-definitions/shopify.json" with { type: "json" }; -import a14 from "./app-definitions/composio.json" with { type: "json" }; -import a15 from "./app-definitions/oauth-generic.json" with { type: "json" }; -import a16 from "./app-definitions/api-key-generic.json" with { type: "json" }; -import a17 from "./app-definitions/sentry.json" with { type: "json" }; -import a18 from "./app-definitions/vercel.json" with { type: "json" }; -import a19 from "./app-definitions/anthropic.json" with { type: "json" }; -import a20 from "./app-definitions/jira.json" with { type: "json" }; -import a21 from "./app-definitions/airtable.json" with { type: "json" }; -import a22 from "./app-definitions/beehiiv.json" with { type: "json" }; -import a23 from "./app-definitions/bitly.json" with { type: "json" }; -import a24 from "./app-definitions/candid.json" with { type: "json" }; -import a25 from "./app-definitions/cloudflare.json" with { type: "json" }; -import a26 from "./app-definitions/cloudinary.json" with { type: "json" }; -import a27 from "./app-definitions/coda.json" with { type: "json" }; -import a28 from "./app-definitions/hugging-face.json" with { type: "json" }; -import a29 from "./app-definitions/kernel.json" with { type: "json" }; -import a30 from "./app-definitions/local-falcon.json" with { type: "json" }; -import a31 from "./app-definitions/make.json" with { type: "json" }; -import a32 from "./app-definitions/manufact.json" with { type: "json" }; -import a33 from "./app-definitions/miro.json" with { type: "json" }; -import a34 from "./app-definitions/netlify.json" with { type: "json" }; -import a35 from "./app-definitions/oreilly.json" with { type: "json" }; -import a36 from "./app-definitions/planetscale.json" with { type: "json" }; -import a37 from "./app-definitions/resend.json" with { type: "json" }; -import a38 from "./app-definitions/ticktick.json" with { type: "json" }; -import a39 from "./app-definitions/todoist.json" with { type: "json" }; -import a40 from "./app-definitions/webflow.json" with { type: "json" }; -import a41 from "./app-definitions/wix.json" with { type: "json" }; -import a42 from "./app-definitions/brex.json" with { type: "json" }; -import a43 from "./app-definitions/clickhouse.json" with { type: "json" }; -import a44 from "./app-definitions/egnyte.json" with { type: "json" }; -import a45 from "./app-definitions/embat.json" with { type: "json" }; -import a46 from "./app-definitions/mixpanel.json" with { type: "json" }; -import a47 from "./app-definitions/postman.json" with { type: "json" }; -import a48 from "./app-definitions/razorpay.json" with { type: "json" }; -import a49 from "./app-definitions/sanity.json" with { type: "json" }; -import a50 from "./app-definitions/stripe.json" with { type: "json" }; -import a51 from "./app-definitions/supabase.json" with { type: "json" }; -import a52 from "./app-definitions/ticket-tailor.json" with { type: "json" }; -import a53 from "./app-definitions/asana.json" with { type: "json" }; -import a54 from "./app-definitions/box.json" with { type: "json" }; -import a55 from "./app-definitions/mem0.json" with { type: "json" }; -import a56 from "./app-definitions/pagerduty.json" with { type: "json" }; -import a57 from "./app-definitions/similarweb.json" with { type: "json" }; -import a58 from "./app-definitions/xero.json" with { type: "json" }; -import a59 from "./app-definitions/youcom.json" with { type: "json" }; -import a60 from "./app-definitions/gmail.json" with { type: "json" }; -import a61 from "./app-definitions/google-drive.json" with { type: "json" }; -import a62 from "./app-definitions/google-docs.json" with { type: "json" }; -import a63 from "./app-definitions/google-sheets.json" with { type: "json" }; -import a64 from "./app-definitions/google-slides.json" with { type: "json" }; -import a65 from "./app-definitions/google-calendar.json" with { type: "json" }; -import a66 from "./app-definitions/google-chat.json" with { type: "json" }; -import a67 from "./app-definitions/google-people.json" with { type: "json" }; -import a68 from "./app-definitions/google-workspace-search.json" with { type: "json" }; -import a69 from "./app-definitions/openai.json" with { type: "json" }; -import a70 from "./app-definitions/openrouter.json" with { type: "json" }; -import a71 from "./app-definitions/xai.json" with { type: "json" }; +import a2 from "./app-definitions/arcade.json" with { type: "json" }; +import a3 from "./app-definitions/executor.json" with { type: "json" }; +import a4 from "./app-definitions/railway.json" with { type: "json" }; +import a5 from "./app-definitions/github.json" with { type: "json" }; +import a6 from "./app-definitions/slack.json" with { type: "json" }; +import a7 from "./app-definitions/microsoft-teams.json" with { type: "json" }; +import a8 from "./app-definitions/imessage-photon.json" with { type: "json" }; +import a9 from "./app-definitions/telegram.json" with { type: "json" }; +import a10 from "./app-definitions/discord.json" with { type: "json" }; +import a11 from "./app-definitions/notion.json" with { type: "json" }; +import a12 from "./app-definitions/posthog.json" with { type: "json" }; +import a13 from "./app-definitions/linear.json" with { type: "json" }; +import a14 from "./app-definitions/context7.json" with { type: "json" }; +import a15 from "./app-definitions/shopify.json" with { type: "json" }; +import a16 from "./app-definitions/composio.json" with { type: "json" }; +import a17 from "./app-definitions/oauth-generic.json" with { type: "json" }; +import a18 from "./app-definitions/api-key-generic.json" with { type: "json" }; +import a19 from "./app-definitions/sentry.json" with { type: "json" }; +import a20 from "./app-definitions/vercel.json" with { type: "json" }; +import a21 from "./app-definitions/anthropic.json" with { type: "json" }; +import a22 from "./app-definitions/jira.json" with { type: "json" }; +import a23 from "./app-definitions/airtable.json" with { type: "json" }; +import a24 from "./app-definitions/beehiiv.json" with { type: "json" }; +import a25 from "./app-definitions/bitly.json" with { type: "json" }; +import a26 from "./app-definitions/candid.json" with { type: "json" }; +import a27 from "./app-definitions/cloudflare.json" with { type: "json" }; +import a28 from "./app-definitions/cloudinary.json" with { type: "json" }; +import a29 from "./app-definitions/coda.json" with { type: "json" }; +import a30 from "./app-definitions/hugging-face.json" with { type: "json" }; +import a31 from "./app-definitions/kernel.json" with { type: "json" }; +import a32 from "./app-definitions/local-falcon.json" with { type: "json" }; +import a33 from "./app-definitions/make.json" with { type: "json" }; +import a34 from "./app-definitions/manufact.json" with { type: "json" }; +import a35 from "./app-definitions/miro.json" with { type: "json" }; +import a36 from "./app-definitions/netlify.json" with { type: "json" }; +import a37 from "./app-definitions/oreilly.json" with { type: "json" }; +import a38 from "./app-definitions/planetscale.json" with { type: "json" }; +import a39 from "./app-definitions/resend.json" with { type: "json" }; +import a40 from "./app-definitions/ticktick.json" with { type: "json" }; +import a41 from "./app-definitions/todoist.json" with { type: "json" }; +import a42 from "./app-definitions/webflow.json" with { type: "json" }; +import a43 from "./app-definitions/wix.json" with { type: "json" }; +import a44 from "./app-definitions/brex.json" with { type: "json" }; +import a45 from "./app-definitions/clickhouse.json" with { type: "json" }; +import a46 from "./app-definitions/egnyte.json" with { type: "json" }; +import a47 from "./app-definitions/embat.json" with { type: "json" }; +import a48 from "./app-definitions/mixpanel.json" with { type: "json" }; +import a49 from "./app-definitions/postman.json" with { type: "json" }; +import a50 from "./app-definitions/razorpay.json" with { type: "json" }; +import a51 from "./app-definitions/sanity.json" with { type: "json" }; +import a52 from "./app-definitions/stripe.json" with { type: "json" }; +import a53 from "./app-definitions/supabase.json" with { type: "json" }; +import a54 from "./app-definitions/ticket-tailor.json" with { type: "json" }; +import a55 from "./app-definitions/asana.json" with { type: "json" }; +import a56 from "./app-definitions/box.json" with { type: "json" }; +import a57 from "./app-definitions/mem0.json" with { type: "json" }; +import a58 from "./app-definitions/pagerduty.json" with { type: "json" }; +import a59 from "./app-definitions/similarweb.json" with { type: "json" }; +import a60 from "./app-definitions/xero.json" with { type: "json" }; +import a61 from "./app-definitions/youcom.json" with { type: "json" }; +import a62 from "./app-definitions/gmail.json" with { type: "json" }; +import a63 from "./app-definitions/google-drive.json" with { type: "json" }; +import a64 from "./app-definitions/google-docs.json" with { type: "json" }; +import a65 from "./app-definitions/google-sheets.json" with { type: "json" }; +import a66 from "./app-definitions/google-slides.json" with { type: "json" }; +import a67 from "./app-definitions/google-calendar.json" with { type: "json" }; +import a68 from "./app-definitions/google-chat.json" with { type: "json" }; +import a69 from "./app-definitions/google-people.json" with { type: "json" }; +import a70 from "./app-definitions/google-workspace-search.json" with { type: "json" }; +import a71 from "./app-definitions/openai.json" with { type: "json" }; +import a72 from "./app-definitions/openrouter.json" with { type: "json" }; +import a73 from "./app-definitions/xai.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] 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] as AppDefinition[]; diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index e1ae262d54..b4ae9cd614 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -692,7 +692,6 @@ describe("AppDefinition catalog", () => { "brex", "candid", "coda", - "composio", "context7", "egnyte", "embat", @@ -709,7 +708,7 @@ describe("AppDefinition catalog", () => { "ticktick", "xero", ]); - expect(APP_STORE_DEFINITIONS).toHaveLength(48); + expect(APP_STORE_DEFINITIONS).toHaveLength(51); const connectableSlugs = new Set( CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug), ); diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts index 0ff98f037e..1e16e35aa2 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -8,6 +8,8 @@ export const CONNECTABLE_APP_SLUGS = new Set([ "agentmail", ...SELF_SERVE_MCP_CANDIDATES.map((entry) => entry.slug), "zapier", + "arcade", + "executor", "slack", "notion", "railway", @@ -48,7 +50,6 @@ export const APP_STORE_HIDDEN_SLUGS = new Set([ "brex", "candid", "coda", - "composio", "context7", "egnyte", "embat", diff --git a/packages/shared/src/app-definitions/arcade.json b/packages/shared/src/app-definitions/arcade.json new file mode 100644 index 0000000000..c837945005 --- /dev/null +++ b/packages/shared/src/app-definitions/arcade.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "slug": "arcade", + "name": "Arcade", + "description": "Use the tools exposed by your Arcade MCP connection.", + "categories": [ + "productivity" + ], + "featured": true, + "branding": { + "logoUrl": "/brands/apps/arcade.png" + }, + "urlPatterns": [ + "https://api.arcade.dev/*" + ], + "methods": [ + { + "key": "mcp", + "transport": "mcp_remote", + "auth": "none", + "ownershipModes": [ + "dcr", + "customer" + ], + "whenToUse": "Use the provider-hosted connection for the quickest setup.", + "defaults": {}, + "guidanceMd": "Paste your Arcade MCP URL. Sign in if required, or add a token or headers under Advanced authentication.", + "riskTier": "S3", + "label": "Connect MCP server", + "consoleLinks": { + "docs": "https://docs.arcade.dev/en/operate/governance/mcp-gateways" + } + } + ], + "docsUrl": "https://docs.arcade.dev/en/operate/governance/mcp-gateways" +} diff --git a/packages/shared/src/app-definitions/composio.json b/packages/shared/src/app-definitions/composio.json index 29f3efe967..b5c880da38 100644 --- a/packages/shared/src/app-definitions/composio.json +++ b/packages/shared/src/app-definitions/composio.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "slug": "composio", "name": "Composio", - "description": "Connect Composio so Paperclip can discover and manage the toolkits in your project.", + "description": "Discover and use connected apps through Composio Connect.", "categories": [ "productivity" ], @@ -12,9 +12,28 @@ "darkLogoUrl": "/brands/apps/composio-dark.svg" }, "urlPatterns": [ - "https://backend.composio.dev/*" + "https://backend.composio.dev/*", + "https://connect.composio.dev/*", + "https://mcp.composio.dev/*", + "https://*.composio.dev/*" ], "methods": [ + { + "key": "mcp", + "transport": "mcp_remote", + "auth": "none", + "ownershipModes": [ + "dcr", + "customer" + ], + "whenToUse": "Use the provider-hosted connection for the quickest setup.", + "defaults": { + "serverUrl": "https://connect.composio.dev/mcp" + }, + "guidanceMd": "Sign in to Composio Connect, or paste an externally configured MCP session URL and headers.", + "riskTier": "S3", + "label": "Composio Connect" + }, { "key": "api-key", "transport": "rest_api", @@ -48,5 +67,6 @@ "docs": "https://docs.composio.dev/reference/authenticating-to-composio/project-api-key-permissions" } } - ] + ], + "docsUrl": "https://docs.composio.dev/docs/composio-connect" } diff --git a/packages/shared/src/app-definitions/executor.json b/packages/shared/src/app-definitions/executor.json new file mode 100644 index 0000000000..f01e0cd4e1 --- /dev/null +++ b/packages/shared/src/app-definitions/executor.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "slug": "executor", + "name": "Executor", + "description": "Use the tools exposed by your Executor MCP connection.", + "categories": [ + "productivity" + ], + "featured": true, + "branding": { + "logoUrl": "/brands/apps/executor.png" + }, + "urlPatterns": [ + "https://executor.sh/*" + ], + "methods": [ + { + "key": "mcp", + "transport": "mcp_remote", + "auth": "none", + "ownershipModes": [ + "dcr", + "customer" + ], + "whenToUse": "Use the provider-hosted connection for the quickest setup.", + "defaults": {}, + "guidanceMd": "Paste your Executor MCP URL. Sign in if required, or add a token or headers under Advanced authentication.", + "riskTier": "S3", + "label": "Connect MCP server", + "consoleLinks": { + "docs": "https://executor.sh/docs/mcp-proxy" + } + } + ], + "docsUrl": "https://executor.sh/docs/mcp-proxy" +} diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 879661d9dd..00e4945e9f 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -116,6 +116,14 @@ export const INSTANCE_FEATURE_CATALOG: Record { + it("requires an explicit MCP aggregators opt-in for self-hosted and managed instances", () => { + expect(instanceExperimentalSettingsSchema.parse({}).enableMcpAggregators).toBe(false); + expect(patchInstanceExperimentalSettingsSchema.parse({ enableMcpAggregators: true })).toEqual({ enableMcpAggregators: true }); + expect(patchInstanceExperimentalSettingsSchema.parse({})).not.toHaveProperty("enableMcpAggregators"); + expect(INSTANCE_FEATURE_CATALOG.enableMcpAggregators).toMatchObject({ tier: "managed", cloudDefault: false, selfHostedDefault: false }); + }); + for (const [provider, methodKey] of Object.entries(REMOTE_MCP_CONNECTOR_METHODS)) { + it(`${provider} has its own catalog and accepts URL plus explicit credentials`, () => { + const definition = getConnectableAppDefinition(provider)!; + expect(definition.slug).toBe(provider); + const method = definition.methods.find((method) => method.key === methodKey)!; + expect(method.transport).toBe("mcp_remote"); + if (provider !== "zapier") expect(method.ownershipModes).toContain("dcr"); + expect(connectToolAppSchema.safeParse({ galleryKey: provider, connectionMethodKey: methodKey, link: "https://example.com/mcp", authMode: "bearer", credentialValues: { "credentials.authorization": "test-secret", "headers.X-User-ID": "test-user" } }).success).toBe(true); + }); + } + it("keeps the legacy Composio API-key broker distinct", () => { + expect(isRemoteMcpConnectorMethod("composio", "api-key")).toBe(false); + expect(getConnectableAppDefinition("composio")?.methods.find((method) => method.key === "api-key")?.transport).toBe("rest_api"); + expect(connectToolAppSchema.safeParse({ galleryKey: "composio", connectionMethodKey: "api-key", authMode: "bearer" }).success).toBe(false); + }); + it("allows removing all agent access without changing the tool choices", () => { + expect(finishToolAppSchema.safeParse({ access: { agentIds: [] } }).success).toBe(true); + expect(finishToolAppSchema.safeParse({ access: { agentIds: ["not-an-agent-id"] } }).success).toBe(false); + }); + it("rejects unsafe header overrides and draft saving on unrelated connectors", () => { + expect(connectToolAppSchema.safeParse({ galleryKey: "arcade", connectionMethodKey: "mcp", credentialValues: { "headers.Host": "evil.example" } }).success).toBe(false); + expect(connectToolAppSchema.safeParse({ galleryKey: "github", saveDraft: true }).success).toBe(false); + }); +}); diff --git a/packages/shared/src/remote-mcp-connectors.ts b/packages/shared/src/remote-mcp-connectors.ts new file mode 100644 index 0000000000..a32e4d9fff --- /dev/null +++ b/packages/shared/src/remote-mcp-connectors.ts @@ -0,0 +1,15 @@ +/** Providers whose own MCP endpoint defines the catalog and authentication. */ +export const REMOTE_MCP_CONNECTOR_METHODS = { + zapier: "generated-url", + arcade: "mcp", + composio: "mcp", + executor: "mcp", +} as const; + +export type RemoteMcpConnectorId = keyof typeof REMOTE_MCP_CONNECTOR_METHODS; +export function isRemoteMcpConnectorId(value: unknown): value is RemoteMcpConnectorId { + return typeof value === "string" && Object.hasOwn(REMOTE_MCP_CONNECTOR_METHODS, value); +} +export function isRemoteMcpConnectorMethod(provider: unknown, method: unknown): boolean { + return isRemoteMcpConnectorId(provider) && method === REMOTE_MCP_CONNECTOR_METHODS[provider]; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index d950a57212..f7e61aa8c4 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -608,6 +608,7 @@ export type { ToolConnectionTestAgentAccessResponse, ToolConnectionTestAgentsResponse, ToolConnectionTestCallResult, + ToolUpstreamPending, ToolConnectionTestCallStatus, ToolConnectionTestCallStatusPhase, } from "./tool-access.js"; diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 2eb8da2094..5ed400e3b2 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -71,6 +71,8 @@ export interface InstanceExperimentalSettings { enableApps: boolean; /** Exposes chat connector setup and Board surfaces; existing delivery continues when hidden. */ enableChatConnectors: boolean; + /** Exposes MCP aggregator setup; existing connections keep running when hidden. */ + enableMcpAggregators: boolean; enablePipelines: boolean; enableCases: boolean; enableAgentChat: boolean; diff --git a/packages/shared/src/types/tool-access.ts b/packages/shared/src/types/tool-access.ts index db50522bca..527bf6f8de 100644 --- a/packages/shared/src/types/tool-access.ts +++ b/packages/shared/src/types/tool-access.ts @@ -1657,6 +1657,17 @@ export interface ToolConnectionTestAgentAccessResponse { access: ToolConnectionAccessSummary; } +export interface ToolUpstreamPending { + kind: "authorization" | "approval"; + links: Array<{ url: string; host: string; elicitationId?: string }>; + executionId?: string; + elicitationId?: string; + resumeTool?: string; + expiresAt?: string; + message?: string; + requestedSchema?: Record; +} + /** Result of `POST /tool-connections/:id/test-calls`. */ export interface ToolConnectionTestCallResult { decision: ToolConnectionTestDecision; @@ -1667,6 +1678,8 @@ export interface ToolConnectionTestCallResult { error?: { message: string; reasonCode: ToolAccessReasonCode | string | null }; /** Present (with `decision: "ask_first"`) — the parked approval request. */ actionRequestId?: string; + /** Provider handoff, distinct from a Paperclip permission approval. */ + upstreamPending?: ToolUpstreamPending; } /** @@ -1692,6 +1705,7 @@ export interface ToolConnectionTestCallStatus { parameters?: Record | null; /** Present once `phase === "done"` and the tool succeeded. */ result?: unknown; + upstreamPending?: ToolUpstreamPending; /** Present once `phase === "done"` and the tool failed, or when the request was denied/expired. */ error?: { message: string; reasonCode: ToolAccessReasonCode | string | null }; /** Wall-clock duration of the executed call in ms, when known. */ diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 3380139e20..136386708a 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -52,6 +52,7 @@ export const instanceExperimentalSettingsSchema = z.object({ // configs continue to load during upgrades. enableApps: z.boolean().default(true), enableChatConnectors: z.boolean().default(false), + enableMcpAggregators: z.boolean().default(false), enablePipelines: z.boolean().default(false), enableCases: z.boolean().default(false), enableAgentChat: z.boolean().default(false), diff --git a/packages/shared/src/validators/tool-access.ts b/packages/shared/src/validators/tool-access.ts index f1da754604..3933aa3495 100644 --- a/packages/shared/src/validators/tool-access.ts +++ b/packages/shared/src/validators/tool-access.ts @@ -1,3 +1,4 @@ +import { isRemoteMcpConnectorMethod } from "../remote-mcp-connectors.js"; import { z } from "zod"; import { CONNECTION_TOKEN_ISSUANCE_PATHS, @@ -419,6 +420,7 @@ export const connectToolAppSchema = z.object({ resumeConnectionId: z.string().guid().optional(), /** Exact configured connection to reauthorize without replacing its identity. */ reconnectConnectionId: z.string().guid().optional(), + saveDraft: z.boolean().optional(), authMode: genericMcpAuthModeSchema.optional(), oauthClient: genericMcpOAuthClientSchema.optional(), credentialSource: z.enum(["paperclip_vault", "vercel_connect"]).optional(), @@ -438,7 +440,10 @@ export const connectToolAppSchema = z.object({ if ((value.grantKind === "agent") !== Boolean(value.subjectAgentId)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["subjectAgentId"], message: "subjectAgentId is required exactly for an agent grant" }); } - if (value.authMode && value.galleryKey) { + if (value.saveDraft && !isRemoteMcpConnectorMethod(value.galleryKey, value.connectionMethodKey)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["saveDraft"], message: "Draft saving requires a remote MCP connector" }); + } + if (value.authMode && value.galleryKey && !isRemoteMcpConnectorMethod(value.galleryKey, value.connectionMethodKey)) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["authMode"], @@ -490,7 +495,8 @@ export const finishToolAppSchema = z.object({ reviewedCatalogEntryIds: z.array(z.string().guid()).max(500).optional(), access: z.union([ z.literal("all_agents"), - z.object({ agentIds: z.array(z.string().guid()).min(1).max(250) }), + // Choosing specific agents may intentionally leave the connection unassigned. + z.object({ agentIds: z.array(z.string().guid()).max(250) }), ]), }); diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index 1e33b374e0..cc8ca176be 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -207,6 +207,16 @@ const apps = [ }, ), ], + ...[ + ["arcade", "Arcade", "https://api.arcade.dev/*", "https://docs.arcade.dev/en/operate/governance/mcp-gateways"], + ["executor", "Executor", "https://executor.sh/*", "https://executor.sh/docs/mcp-proxy"], + ].map(([slug, name, pattern, docsUrl]) => [ + slug, name, `Use the tools exposed by your ${name} MCP connection.`, "productivity", new URL(pattern).hostname, [pattern], + method("mcp", "mcp_remote", "none", {}, "S3", `Paste your ${name} MCP URL. Sign in if required, or add a token or headers under Advanced authentication.`, { + label: "Connect MCP server", ownershipModes: ["dcr", "customer"], consoleLinks: { docs: docsUrl }, + }), + { featured: true, docsUrl }, + ]), [ "railway", "Railway", @@ -693,11 +703,11 @@ const apps = [ [ "composio", "Composio", - "Connect Composio so Paperclip can discover and manage the toolkits in your project.", + "Discover and use connected apps through Composio Connect.", "productivity", "composio.dev", - ["https://backend.composio.dev/*"], - method( + ["https://backend.composio.dev/*", "https://connect.composio.dev/*", "https://mcp.composio.dev/*", "https://*.composio.dev/*"], + [method("mcp", "mcp_remote", "none", { serverUrl: "https://connect.composio.dev/mcp" }, "S3", "Sign in to Composio Connect, or paste an externally configured MCP session URL and headers.", { label: "Composio Connect", ownershipModes: ["dcr", "customer"] }), method( "api-key", "rest_api", "api_key", @@ -721,8 +731,8 @@ const apps = [ docs: "https://docs.composio.dev/reference/authenticating-to-composio/project-api-key-permissions", }, }, - ), - { featured: true }, + )], + { featured: true, docsUrl: "https://docs.composio.dev/docs/composio-connect" }, ], [ "oauth-generic", diff --git a/server/src/__tests__/generic-mcp-connection.test.ts b/server/src/__tests__/generic-mcp-connection.test.ts index 09fb4aae4f..77b7c16580 100644 --- a/server/src/__tests__/generic-mcp-connection.test.ts +++ b/server/src/__tests__/generic-mcp-connection.test.ts @@ -39,6 +39,7 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { toolAccessService } from "../services/tool-access.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; import { ComposioApiError, type ComposioClient } from "../services/composio.js"; import { createComposioSessionManager } from "../services/composio-session-manager.js"; import { toolAccessPolicyService } from "../services/tool-access-policy.js"; @@ -468,6 +469,7 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { }); it("keeps a generated Zapier URL attached to the curated Zapier identity", async () => { + await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true }); const secretUrl = "https://mcp.zapier.com/api/v1/connect?token=zapier-secret"; const publicUrl = "https://mcp.zapier.com/api/v1/connect"; const company = await createCompany(db); @@ -477,10 +479,12 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }], remoteHttpRequest: async (url, init) => { if (url === secretUrl && (init.method ?? "GET").toUpperCase() === "POST") { + const body = JSON.parse(String(init.body)); + if (body.method === "notifications/initialized") return new Response(null, { status: 202 }); return jsonResponse({ jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools: FIXTURE_TOOLS }, + id: body.id, + result: body.method === "initialize" ? { protocolVersion: "2025-06-18" } : { tools: FIXTURE_TOOLS }, }); } return jsonResponse({ error: "not_found" }, 404); diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 38218a50e6..f5a3cfab06 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -43,6 +43,7 @@ describe("instance settings service", () => { enableApps: true, enableAgentChat: false, enableChatConnectors: false, + enableMcpAggregators: false, enableConferenceRoomChat: false, enableClassicTaskInterface: false, enableExternalObjects: false, diff --git a/server/src/__tests__/remote-mcp-connectors.test.ts b/server/src/__tests__/remote-mcp-connectors.test.ts new file mode 100644 index 0000000000..48b7bb7270 --- /dev/null +++ b/server/src/__tests__/remote-mcp-connectors.test.ts @@ -0,0 +1,251 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { agents, heartbeatRuns, issues, toolCatalogEntries, toolConnectionInstalls, toolInvocations, companies, companyMemberships, createDb, toolConnections, toolPolicies, toolProfileEntries } from "@paperclipai/db"; +import { eq } from "drizzle-orm"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { toolAccessService } from "../services/tool-access.js"; +import { createToolGatewayService } from "../services/tool-gateway.js"; +import { instanceSettingsService, normalizeExperimentalSettings } from "../services/instance-settings.js"; +import express from "express"; +import request from "supertest"; +import { toolAccessRoutes } from "../routes/tool-access.js"; +const actor = { actorType: "user" as const, actorId: "mcp-test-user", actorSource: "local_implicit" as const }; +const tool = (name: string) => ({ name, description: name, inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true } }); +describe("remote connector lifecycle", () => { + let fixture: Awaited>; + let db: ReturnType; + let keyDir: string; + beforeAll(async () => { + keyDir = await mkdtemp(join(tmpdir(), "mcp-connector-secrets-")); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(keyDir, "key")); + fixture = await startEmbeddedPostgresTestDatabase("mcp-connectors-test-"); + db = createDb(fixture.connectionString); + await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true }); + }); + afterAll(async () => { await fixture?.cleanup(); vi.unstubAllEnvs(); if (keyDir) await rm(keyDir, { recursive: true, force: true }); }); + async function company() { + const [row] = await db.insert(companies).values({ name: `MCP ${randomUUID()}`, issuePrefix: `M${randomUUID().slice(0, 5)}` }).returning(); + await db.insert(companyMemberships).values({ companyId: row.id, principalType: "user", principalId: actor.actorId, status: "active", membershipRole: "admin" }); + return row; + } + function remoteFixture() { + const requests: { url: string; headers: Headers }[] = []; + let added = false; + let removed = false; + const service = toolAccessService(db, { deploymentMode: "local_trusted", deploymentExposure: "private", + remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }], + remoteHttpRequest: async (url, init) => { + const body = JSON.parse(String(init.body)); const headers = new Headers(init.headers); + requests.push({ url, headers }); + if (body.method === "initialize") return Response.json({ id: body.id, jsonrpc: "2.0", result: { protocolVersion: "2025-06-18", capabilities: {} } }, { headers: { "Mcp-Session-Id": "session" } }); + expect(headers.get("mcp-session-id")).toBe("session"); + if (body.method === "notifications/initialized") return new Response(null, { status: 202 }); + expect(body.method).toBe("tools/list"); + return Response.json({ id: body.id, jsonrpc: "2.0", result: body.params?.cursor + ? { tools: [tool("ask"), ...(added ? [tool("new_tool")] : [])] } + : { tools: [tool("read"), ...(!removed ? [tool("off")] : [])], nextCursor: "page-two" } }); + }, + }); + return { service, requests, add: () => { added = true; }, remove: () => { removed = true; }, restore: () => { removed = false; } }; + } + it("defaults MCP aggregators off and rejects direct setup without provider requests or credential writes", async () => { + expect(normalizeExperimentalSettings({}).enableMcpAggregators).toBe(false); + const org = await company(); const remote = remoteFixture(); + await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: false }); + try { + const app = express(); + app.use((req, _res, next) => { req.actor = { type: "board", userId: actor.actorId, source: "local_implicit", isInstanceAdmin: true }; next(); }); + app.use("/api", toolAccessRoutes(db, { paperclipCloudConnector: null })); + const gallery = await request(app).get(`/api/companies/${org.id}/tools/gallery`); + expect(gallery.status).toBe(200); + expect(gallery.body.apps.some((entry: { slug: string }) => ["zapier", "arcade", "composio", "executor"].includes(entry.slug))).toBe(false); + expect(gallery.body.apps.some((entry: { slug: string }) => entry.slug === "notion")).toBe(true); + for (const [galleryKey, connectionMethodKey] of [["zapier", "generated-url"], ["arcade", "mcp"], ["composio", "mcp"], ["executor", "mcp"]]) { + await expect(remote.service.connectGalleryApp(org.id, { galleryKey, connectionMethodKey, saveDraft: true }, actor)) + .rejects.toMatchObject({ status: 403, details: { code: "mcp_aggregators_disabled" } }); + } + await expect(remote.service.preflightGalleryAppMetadata("composio", "mcp")) + .rejects.toMatchObject({ status: 403 }); + expect(remote.requests).toHaveLength(0); + expect(await db.select().from(toolConnections).where(eq(toolConnections.companyId, org.id))).toHaveLength(0); + } finally { + await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true }); + } + }); + it("vaults generated URLs, paginates discovery, preserves Off/Ask during refresh and reconnect, and isolates companies", async () => { + const org = await company(); const other = await company(); const remote = remoteFixture(); + const input = { galleryKey: "zapier", connectionMethodKey: "generated-url", link: "https://mcp.zapier.com/api/v1/connect?token=fixture-secret", authMode: "none" as const }; + const connected = await remote.service.connectGalleryApp(org.id, input, actor); + expect(connected.catalog).toHaveLength(3); + expect(JSON.stringify(connected)).not.toContain("fixture-secret"); + expect(remote.requests.every((r) => r.url.includes("token=fixture-secret"))).toBe(true); + const ids = Object.fromEntries(connected.catalog.map((entry) => [entry.toolName, entry.id])); + const finished = await remote.service.finishGalleryAppConnection(org.id, connected.connectionId, { enabledCatalogEntryIds: [ids.read, ids.ask], askFirstCatalogEntryIds: [ids.ask], access: "all_agents" }, actor); + remote.add(); + const refreshed = await remote.service.refreshCatalog(connected.connectionId, actor, { enableAllByDefault: true }); + const newId = refreshed.catalog.find((entry) => entry.toolName === "new_tool")!.id; + const entries = await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.profileId, finished.profile.id)); + expect(entries.map((entry) => entry.catalogEntryId)).toEqual(expect.arrayContaining([ids.read, ids.ask, newId])); + expect(entries.map((entry) => entry.catalogEntryId)).not.toContain(ids.off); + await expect(remote.service.connectGalleryApp(other.id, { ...input, reconnectConnectionId: connected.connectionId }, actor)).rejects.toThrow("not found"); + const reconnected = await remote.service.connectGalleryApp(org.id, { ...input, reconnectConnectionId: connected.connectionId }, actor); + const restored = await remote.service.finishGalleryAppConnection(org.id, connected.connectionId, { enabledCatalogEntryIds: reconnected.catalog.map((entry) => entry.id), askFirstCatalogEntryIds: [], access: "all_agents" }, actor); + expect(restored.connection.status).toBe("active"); + expect(restored.profileEntries.map((entry) => entry.catalogEntryId)).not.toContain(ids.off); + const policies = await db.select().from(toolPolicies).where(eq(toolPolicies.companyId, org.id)); + expect(policies.some((policy) => policy.enabled && policy.config.catalogEntryId === ids.ask)).toBe(true); + remote.remove(); + const afterRemoval = await remote.service.refreshCatalog(connected.connectionId, actor); + expect(afterRemoval.catalog.find((entry) => entry.toolName === "off")?.status).not.toBe("active"); + const [retired] = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.id, ids.off)); + expect(retired.status).toBe("disabled"); // Check persisted discovery, not just this refresh response. + remote.restore(); + const afterReturn = await remote.service.refreshCatalog(connected.connectionId, actor); + expect(afterReturn.catalog.find((entry) => entry.toolName === "off")?.status).toBe("active"); + const retainedEntries = await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.profileId, finished.profile.id)); + expect(retainedEntries.map((entry) => entry.catalogEntryId)).not.toContain(ids.off); + await remote.service.archiveConnection(connected.connectionId, org.id, actor); + const [removed] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId)); + expect(removed.enabled).toBe(false); + expect(removed.status).toBe("archived"); + }); + it("renews expired discovery sessions and requires an explicit retry after an expired execution session", async () => { + const org = await company(); + const [agent] = await db.insert(agents).values({ companyId: org.id, name: "Session tester", role: "engineer", adapterType: "process", adapterConfig: {}, runtimeConfig: {} }).returning(); + let initialized = 0; + const expired = new Set(); + const calls: string[] = []; + const send = async (_url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)); + if (body.method === "initialize") return Response.json({ id: body.id, result: { protocolVersion: "2025-06-18" } }, { headers: { "Mcp-Session-Id": `session-${++initialized}` } }); + if (body.method === "notifications/initialized") return new Response(null, { status: 202 }); + const session = new Headers(init.headers).get("mcp-session-id")!; + if (body.method === "tools/call") calls.push(session); + if (expired.has(session)) return new Response(null, { status: 404 }); + return Response.json({ id: body.id, result: body.method === "tools/list" ? { tools: [tool("read")] } : { content: [{ type: "text", text: "ok" }] } }); + }; + const service = toolAccessService(db, { deploymentMode: "local_trusted", deploymentExposure: "private", remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }], remoteHttpRequest: send }); + const connected = await service.connectGalleryApp(org.id, { galleryKey: "arcade", connectionMethodKey: "mcp", link: "https://api.arcade.dev/mcp/expiration", authMode: "none" }, actor); + const beforeRefresh = initialized; + expired.add(`session-${initialized}`); + expect((await service.refreshCatalog(connected.connectionId, actor)).catalog).toHaveLength(1); + expect(initialized).toBe(beforeRefresh + 1); + await service.finishGalleryAppConnection(org.id, connected.connectionId, { enabledCatalogEntryIds: connected.catalog.map((entry) => entry.id), askFirstCatalogEntryIds: [], access: "all_agents" }, actor); + const gateway = createToolGatewayService(db, { deploymentMode: "local_trusted", deploymentExposure: "private", remoteHttpRequest: send }); + const call = () => gateway.executeTestCall({ companyId: org.id, connectionId: connected.connectionId, agentId: agent.id, userId: actor.actorId, toolName: "read", parameters: {} }); + expect(await call()).toMatchObject({ decision: "allowed", result: { data: { isError: false } } }); + const executionSession = calls[0]; + expired.add(executionSession); + const failed = await call(); + expect(failed).toMatchObject({ error: { reasonCode: "mcp_remote_status" } }); + expect(calls).toEqual([executionSession, executionSession]); + expect(await call()).toMatchObject({ decision: "allowed", result: { data: { isError: false } } }); + expect(calls).toHaveLength(3); + expect(calls[2]).not.toBe(executionSession); + }); + it("saves a vaulted draft without contacting the provider and resumes with custom headers", async () => { + const org = await company(); const remote = remoteFixture(); + const input = { galleryKey: "arcade", connectionMethodKey: "mcp", link: "https://api.arcade.dev/mcp/fixture", authMode: "bearer" as const }; + const draft = await remote.service.connectGalleryApp(org.id, { ...input, saveDraft: true, credentialValues: { "credentials.authorization": "fixture-key", "headers.Arcade-User-ID": "test-user" } }, actor); + expect(remote.requests).toHaveLength(0); + expect(JSON.stringify(draft)).not.toContain("fixture-key"); + const connected = await remote.service.connectGalleryApp(org.id, { ...input, resumeConnectionId: draft.connectionId }, actor); + expect(connected.catalog).toHaveLength(3); + expect(remote.requests.every((request) => request.headers.get("authorization") === "Bearer fixture-key" && request.headers.get("arcade-user-id") === "test-user")).toBe(true); + }); + it("does not widen an empty agent selection after an OAuth callback or reconnect", async () => { + const org = await company(); + const endpoint = "https://api.arcade.dev/mcp/fixture-oauth"; + const send = async (url: string, init: RequestInit) => { + if (url.includes(".well-known/oauth-protected-resource")) return Response.json({ resource: endpoint, authorization_servers: ["https://auth.arcade.dev"] }); + if (url.includes(".well-known/")) return Response.json({ issuer: "https://auth.arcade.dev", authorization_endpoint: "https://auth.arcade.dev/authorize", token_endpoint: "https://auth.arcade.dev/token", response_types_supported: ["code"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"] }); + if (url === "https://auth.arcade.dev/token") return Response.json({ access_token: "fixture-access", token_type: "Bearer", expires_in: 3600 }); + if (url !== endpoint) throw new Error(`Unexpected fixture URL: ${url}`); + if (!new Headers(init.headers).has("authorization")) return new Response(null, { status: 401, headers: { "WWW-Authenticate": 'Bearer resource_metadata="https://api.arcade.dev/.well-known/oauth-protected-resource"' } }); + const body = JSON.parse(String(init.body)); + if (body.method === "notifications/initialized") return new Response(null, { status: 202 }); + return Response.json({ id: body.id, result: body.method === "initialize" ? { protocolVersion: "2025-06-18" } : { tools: [tool("read")] } }); + }; + const service = toolAccessService(db, { deploymentMode: "local_trusted", deploymentExposure: "private", remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }], remoteHttpRequest: send }); + const input = { galleryKey: "arcade", connectionMethodKey: "mcp", link: endpoint, authMode: "auto" as const, oauthClient: { clientId: "fixture-client" } }; + const connected = await service.connectGalleryApp(org.id, input, actor); + const callback = async () => { + const redirectUri = "http://127.0.0.1:3116/api/tools/oauth/callback"; + const started = await service.startOAuth(org.id, connected.connectionId, { redirectUri, actor }); + return service.completeOAuthCallback({ state: new URL(started.authorizationUrl).searchParams.get("state")!, code: "fixture-code", redirectUri, actor }); + }; + await service.putConnectionInstalls(connected.connectionId, { installs: [] }, actor); + const first = await callback(); + expect(first.connection.status).toBe("active"); + expect((await service.listConnectionInstalls(connected.connectionId))).toHaveLength(0); + await service.connectGalleryApp(org.id, { ...input, reconnectConnectionId: connected.connectionId }, actor); + await callback(); + expect((await service.listConnectionInstalls(connected.connectionId))).toHaveLength(0); + }); + it("enforces agent and action permissions before dispatch, and preserves provider resume after Paperclip approval", async () => { + const org = await company(); + const [allowed, denied] = await db.insert(agents).values(["Allowed", "Denied"].map((name) => ({ companyId: org.id, name, role: "engineer", adapterType: "process", adapterConfig: {}, runtimeConfig: {} }))).returning(); + const calls: { name: string; arguments: unknown; session: string | null }[] = []; + const send = async (_url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)); + if (body.method === "initialize") return Response.json({ id: body.id, result: { protocolVersion: "2025-06-18" } }, { headers: { "Mcp-Session-Id": "execution-session" } }); + if (body.method === "notifications/initialized") return new Response(null, { status: 202 }); + if (body.method === "tools/list") return Response.json({ id: body.id, result: { tools: [tool("execute"), tool("resume"), tool("off")] } }); + calls.push({ ...body.params, session: new Headers(init.headers).get("mcp-session-id") }); + return Response.json({ id: body.id, result: { structuredContent: body.params.name === "execute" + ? { status: "waiting_for_interaction", executionId: "fixture-execution", interaction: { kind: "form", message: "Approve read?", requestedSchema: { type: "object", properties: {} } } } + : { ok: true, resumed: body.params.arguments.executionId }, content: [] } }); + }; + const service = toolAccessService(db, { deploymentMode: "local_trusted", deploymentExposure: "private", remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }], remoteHttpRequest: send }); + const connection = await service.connectGalleryApp(org.id, { galleryKey: "executor", connectionMethodKey: "mcp", link: "https://executor.sh/test/mcp", authMode: "none" }, actor); + const ids = Object.fromEntries(connection.catalog.map((entry) => [entry.toolName, entry.id])); + await service.finishGalleryAppConnection(org.id, connection.connectionId, { enabledCatalogEntryIds: [ids.execute, ids.resume], askFirstCatalogEntryIds: [ids.execute], access: { agentIds: [allowed.id] } }, actor); + const gateway = createToolGatewayService(db, { deploymentMode: "local_trusted", deploymentExposure: "private", remoteHttpRequest: send, toolActionSigningSecret: "fixture-signing-key" }); + const call = (agentId: string, toolName: string, parameters: Record = {}) => gateway.executeTestCall({ companyId: org.id, connectionId: connection.connectionId, agentId, userId: actor.actorId, toolName, parameters }); + expect((await call(denied.id, "execute")).decision).toBe("off"); + expect((await call(allowed.id, "off")).decision).toBe("off"); + expect(calls).toHaveLength(0); + const asked = await call(allowed.id, "execute"); + expect(asked.decision).toBe("ask_first"); + expect(calls).toHaveLength(0); + if (!("actionRequestId" in asked)) throw new Error("Missing approval request"); + await gateway.approveActionRequest({ companyId: org.id, actionRequestId: asked.actionRequestId!, actor: { userId: actor.actorId } }); + const status = await gateway.getTestCallStatus({ companyId: org.id, connectionId: connection.connectionId, actionRequestId: asked.actionRequestId! }); + expect(status.phase).toBe("done"); + expect(status.upstreamPending).toMatchObject({ executionId: "fixture-execution", resumeTool: "resume" }); + const [invocation] = await db.select().from(toolInvocations).where(eq(toolInvocations.id, asked.invocationId)); + expect(invocation.resultSummary?.summary).toContain("fixture-execution"); + const resumed = await call(allowed.id, "resume", { executionId: status.upstreamPending!.executionId, action: "accept", content: "{}" }); + expect(resumed.decision).toBe("allowed"); + expect(calls.map((call) => call.name)).toEqual(["execute", "resume"]); + expect(calls.every((call) => call.session === "execution-session")).toBe(true); + const [issue] = await db.insert(issues).values({ companyId: org.id, title: "Provider approval", status: "in_progress", assigneeAgentId: allowed.id }).returning(); + const [run] = await db.insert(heartbeatRuns).values({ companyId: org.id, agentId: allowed.id, invocationSource: "assignment", status: "running", contextSnapshot: { issueId: issue.id } }).returning(); + const session = await gateway.createSession({ companyId: org.id, agentId: allowed.id, runId: run.id }); + const execute = (await gateway.listToolsForSession(session.token)).find((entry) => entry.upstreamToolName === "execute")!; + expect(execute).toBeDefined(); + let approvalId = ""; + try { await gateway.executeTool({ sessionToken: session.token, tool: execute.name, parameters: {} }); } + catch (error) { + expect(error).toMatchObject({ reasonCode: "approval_required" }); + approvalId = (error as { details: { actionRequestId: string } }).details.actionRequestId; + } + expect(approvalId).not.toBe(""); + await gateway.approveActionRequest({ companyId: org.id, actionRequestId: approvalId, actor: { userId: actor.actorId } }); + await expect(gateway.executeTool({ sessionToken: session.token, tool: execute.name, parameters: {} })).rejects.toMatchObject({ + reasonCode: "provider_interaction_required", details: { upstreamPending: { executionId: "fixture-execution" } }, + }); + expect(calls).toHaveLength(3); // Retrying the approved action did not start another execution. + await service.finishGalleryAppConnection(org.id, connection.connectionId, { enabledCatalogEntryIds: [ids.execute, ids.resume], askFirstCatalogEntryIds: [ids.execute], access: { agentIds: [] } }, actor); + expect(await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, connection.connectionId))).toHaveLength(0); + expect((await call(allowed.id, "execute")).decision).toBe("off"); + expect((await gateway.listToolsForSession(session.token)).filter((entry) => entry.connectionId === connection.connectionId)).toHaveLength(0); + await service.archiveConnection(connection.connectionId, org.id, actor); + await expect(call(allowed.id, "resume")).rejects.toThrow("not found"); + expect(calls).toHaveLength(3); + }); + +}); diff --git a/server/src/__tests__/remote-mcp-pending.test.ts b/server/src/__tests__/remote-mcp-pending.test.ts new file mode 100644 index 0000000000..2da80822e3 --- /dev/null +++ b/server/src/__tests__/remote-mcp-pending.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { extractRemoteMcpPending } from "../services/remote-mcp-pending.js"; +import { classifyRisk } from "../services/tool-access.js"; + +describe("remote MCP provider handoffs", () => { + it("recognizes Arcade's JSON text authorization response before redaction", () => { + const result = { result: { isError: true, content: [{ type: "text", text: JSON.stringify({ authorization_url: "https://github.com/login/oauth/authorize?state=test", message: "Not executed; authorize first" }) }] } }; + expect(extractRemoteMcpPending(result, "arcade")).toMatchObject({ kind: "authorization", links: [{ host: "github.com", url: "https://github.com/login/oauth/authorize?state=test" }] }); + }); + it("recognizes nested Composio connection links without treating ordinary results as handoffs", () => { + expect(extractRemoteMcpPending({ data: { results: [{ redirect_url: "https://connect.composio.dev/link/test" }] } }, "composio", "COMPOSIO_MANAGE_CONNECTIONS")?.links).toHaveLength(1); + expect(extractRemoteMcpPending({ url: "https://example.com/article", execution_id: "completed" }, "executor")).toBeNull(); + expect(extractRemoteMcpPending({ redirect_url: "https://example.com/article" }, "unrelated")).toBeNull(); + }); + it("preserves URL elicitation identities and drops unsafe navigation targets", () => { + const elicitations = [ + { mode: "url", elicitationId: "consent-1", url: "https://provider.example/approve" }, + { mode: "url", elicitationId: "consent-2", url: "javascript:alert(1)" }, + { mode: "url", elicitationId: "consent-3", url: "https://user:password@example.com" }, + ]; + const pending = extractRemoteMcpPending({ error: { code: -32042, data: { elicitations } } }); + expect(pending?.links).toEqual([{ url: "https://provider.example/approve", host: "provider.example", elicitationId: "consent-1" }]); + expect(pending?.elicitationId).toBe("consent-1"); + }); + it("keeps an Executor execution identity for manual resume, without inventing a retry", () => { + expect(extractRemoteMcpPending({ status: "waiting_for_interaction", executionId: "run-42", interaction: { kind: "form", message: "Approve read?", requestedSchema: { type: "object", properties: {} } } }, "executor")).toMatchObject({ kind: "approval", executionId: "run-42", resumeTool: "resume" }); + }); + it("does not turn ordinary successful app data into an approval or authorization handoff", () => { + for (const provider of ["arcade", "composio", "executor"]) { + for (const status of ["suspended", "pending_approval", "awaiting_approval", "waiting_for_interaction"]) { + expect(extractRemoteMcpPending({ result: { structuredContent: { records: [{ status, executionId: "app-job", interaction: { kind: "form" }, authorization_url: "https://example.com/auth", redirect_url: "https://connect.composio.dev/link/test" }] } } }, provider, "get_records")).toBeNull(); + expect(extractRemoteMcpPending({ result: { structuredContent: { status } } }, provider, "get_record")).toBeNull(); + } + expect(extractRemoteMcpPending({ result: { structuredContent: { mode: "url", elicitationId: "app-field", url: "https://example.com" } } }, provider)).toBeNull(); + } + }); + it("classifies broad execution and resume as writes even without annotations", () => { + for (const name of ["execute", "resume", "edit-artifact"]) expect(classifyRisk({ name }, "executor")).toBe("write"); + expect(classifyRisk({ name: "skills", annotations: { readOnlyHint: true } }, "executor")).toBe("read"); + expect(classifyRisk({ name: "COMPOSIO_MULTI_EXECUTE_TOOL", annotations: { readOnlyHint: true } }, "composio")).toBe("write"); + }); + it("defaults unfamiliar and namespaced aggregator capabilities to writes despite read-only hints", () => { + for (const provider of ["executor", "composio", "arcade", "zapier"]) { + for (const name of ["vendor.execute", "custom_resume", "code", "workbench", "new_capability", "get_and_run_action"]) { + for (const annotations of [undefined, { readOnlyHint: true }, { readOnlyHint: false }]) { + expect(classifyRisk({ name, annotations }, provider)).toBe("write"); + } + } + expect(classifyRisk({ name: "delete_everything", annotations: { readOnlyHint: true } }, provider)).toBe("destructive"); + } + for (const [provider, name] of [["executor", "skills"], ["composio", "COMPOSIO_SEARCH_TOOLS"], ["arcade", "Github.GetRepository"]]) { + expect(classifyRisk({ name }, provider)).toBe("read"); + expect(classifyRisk({ name, annotations: { readOnlyHint: false } }, provider)).toBe("write"); + expect(classifyRisk({ name, annotations: { destructiveHint: true } }, provider)).toBe("destructive"); + expect(classifyRisk({ name: `custom.${name}`, annotations: { readOnlyHint: true } }, provider)).toBe("write"); + } + expect(classifyRisk({ name: "GITHUB_LIST_REPOSITORIES", annotations: { readOnlyHint: true } })).toBe("read"); + }); +}); diff --git a/server/src/__tests__/remote-mcp-protocol.test.ts b/server/src/__tests__/remote-mcp-protocol.test.ts new file mode 100644 index 0000000000..1167e514d4 --- /dev/null +++ b/server/src/__tests__/remote-mcp-protocol.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import { forgetMcpHttpSessions, getMcpHttpSession, initializeMcpHttpSession, McpHttpInitializationError, readMcpHttpResponse } from "../services/mcp-http.js"; + +const encoder = new TextEncoder(); +function stream(chunks: string[], close = true) { + const cancel = vi.fn(); + const response = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); if (close) controller.close(); }, cancel }), { headers: { "content-type": "text/event-stream" } }); + return { response, cancel }; +} +function event(message: unknown) { return `data: ${JSON.stringify(message)}\r\n\r\n`; } + +describe("remote connector Streamable HTTP", () => { + it("matches the requested ID after progress and unrelated messages without waiting for stream closure", async () => { + const fixture = stream([event({ jsonrpc: "2.0", method: "notifications/progress", params: { progress: 1 } }), event({ jsonrpc: "2.0", id: "other", result: {} }), event({ jsonrpc: "2.0", id: "call", result: { content: [] } })], false); + expect(await readMcpHttpResponse(fixture.response, "call")).toEqual({ jsonrpc: "2.0", id: "call", result: { content: [] } }); + expect(fixture.cancel).toHaveBeenCalledOnce(); + }); + it("decodes split CRLF and UTF-8 chunks", async () => { + const data = event({ id: 7, result: { text: "café" } }); + expect(await readMcpHttpResponse(stream([...data]).response, 7)).toMatchObject({ result: { text: "café" } }); + }); + it("rejects an unrelated JSON result and bounds streamed responses", async () => { + await expect(readMcpHttpResponse(Response.json({ id: "wrong", result: {} }), "call")).rejects.toThrow("message ID"); + await expect(readMcpHttpResponse(stream([event({ id: 1, result: "too much" })]).response, 1, { maxBytes: 8 })).rejects.toThrow("size limit"); + }); + it("applies matching, parse errors, and size limits to buffered HTTP transports", async () => { + const buffered = (body: string) => ({ headers: new Headers(), text: async () => body }) as Response; + expect(await readMcpHttpResponse(buffered('{"id":"call","result":{}}'), "call")).toMatchObject({ id: "call" }); + await expect(readMcpHttpResponse(buffered('{"id":"other","result":{}}'), "call")).rejects.toMatchObject({ reason: "malformed_response" }); + await expect(readMcpHttpResponse(buffered("not json"), "call")).rejects.toMatchObject({ reason: "invalid_json" }); + await expect(readMcpHttpResponse(buffered("too large"), "call", { maxBytes: 2 })).rejects.toMatchObject({ reason: "too_large" }); + }); + it("delivers server requests before the matching response", async () => { + const onRequest = vi.fn(async () => {}); + const request = { jsonrpc: "2.0", id: "auth", method: "elicitation/create", params: { mode: "url", url: "https://example.com/auth", elicitationId: "consent" } }; + await readMcpHttpResponse(stream([event(request), event({ id: "call", result: {} })]).response, "call", { onRequest }); + expect(onRequest).toHaveBeenCalledWith(request); + }); + it("preserves an initialization authentication challenge for OAuth discovery", async () => { + const response = new Response(null, { status: 401, headers: { "www-authenticate": 'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"' } }); + try { await initializeMcpHttpSession({ requestId: "a", send: async () => response }); throw new Error("Expected challenge"); } + catch (error) { expect(error).toBeInstanceOf(McpHttpInitializationError); expect((error as McpHttpInitializationError).response).toBe(response); } + }); + it("initializes once per connection and identity and resets after revocation", async () => { + let count = 0; + const send = vi.fn(async (init: RequestInit) => { + const body = JSON.parse(String(init.body)); + if (body.method === "initialize") { count++; return Response.json({ jsonrpc: "2.0", id: body.id, result: { protocolVersion: "2025-06-18", capabilities: {} } }, { headers: { "Mcp-Session-Id": `session-${count}` } }); } + expect(body.method).toBe("notifications/initialized"); + return new Response(null, { status: 202 }); + }); + const input = { send, requestId: "a", scope: "connection-a:grant-a", headers: { Authorization: "Bearer secret-a" } }; + const [a, repeated] = await Promise.all([getMcpHttpSession(input), getMcpHttpSession(input)]); + expect(a).toEqual(repeated); + expect((await getMcpHttpSession(input))["Mcp-Session-Id"]).toBe("session-1"); + expect((await getMcpHttpSession({ ...input, scope: "connection-b:grant-a" }))["Mcp-Session-Id"]).toBe("session-2"); + expect((await getMcpHttpSession({ ...input, scope: "connection-a:grant-b" }))["Mcp-Session-Id"]).toBe("session-3"); + expect((await getMcpHttpSession({ ...input, headers: { Authorization: "Bearer rotated" } }))["Mcp-Session-Id"]).toBe("session-4"); + forgetMcpHttpSessions("connection-a"); + expect((await getMcpHttpSession(input))["Mcp-Session-Id"]).toBe("session-5"); + expect(count).toBe(5); + }); + it("does not resurrect an in-flight session after disconnect", async () => { + let finish: (() => void) | undefined; + const gate = new Promise((resolve) => { finish = resolve; }); + const send = async (init: RequestInit) => { + const body = JSON.parse(String(init.body)); + if (body.method === "initialize") { + await gate; + return Response.json({ id: body.id, result: { protocolVersion: "2025-06-18" } }, { headers: { "Mcp-Session-Id": "revoked" } }); + } + return new Response(null, { status: 202 }); + }; + const pending = getMcpHttpSession({ send, requestId: "revoking", scope: "revoking-connection:agent" }); + forgetMcpHttpSessions("revoking-connection"); + finish!(); + await expect(pending).rejects.toThrow("connection changed"); + }); +}); diff --git a/server/src/__tests__/remote-mcp-redaction.test.ts b/server/src/__tests__/remote-mcp-redaction.test.ts new file mode 100644 index 0000000000..65fa64b4ee --- /dev/null +++ b/server/src/__tests__/remote-mcp-redaction.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { redactEventPayload, redactSensitiveText } from "../redaction.js"; + +describe("public Executor selectors and secret redaction", () => { + it("keeps only exact known public helper addresses readable in MCP results", () => { + const path = "executor.coreTools.integrations.list"; + expect(redactEventPayload({ path, text: `Call tools.${path}({})` })).toEqual({ path, text: `Call tools.${path}({})` }); + expect(redactSensitiveText(`${path}.privateSecretSuffix`)).toContain("REDACTED"); + expect(redactEventPayload({ path: "arbitrary.provider.path" })?.path).toContain("REDACTED"); + }); + it("still redacts secret fields, bearer values, and JWT-shaped strings", () => { + const value = "executor.coreTools.integrations.list"; + expect(redactEventPayload({ token: value })?.token).toContain("REDACTED"); + expect(redactSensitiveText(`Authorization: Bearer ${value}`)).not.toContain(value); + expect(redactSensitiveText(`TOKEN=${value}`)).not.toContain(value); + expect(redactSensitiveText("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature12345678")).toContain("REDACTED"); + }); +}); diff --git a/server/src/__tests__/sentry.test.ts b/server/src/__tests__/sentry.test.ts index 5f34a1ff97..030029c9b0 100644 --- a/server/src/__tests__/sentry.test.ts +++ b/server/src/__tests__/sentry.test.ts @@ -546,6 +546,7 @@ describe("buildSentryInitOptions serverName", () => { describe("buildSentryInitOptions release", () => { const commit = "0123456789abcdef0123456789abcdef01234567"; + const readBuildCommit = vi.fn<() => string | null>(); const integrations = { httpIntegration: () => ({ name: "Http" }), onUnhandledRejectionIntegration: () => ({ name: "OnUnhandledRejection" }), @@ -553,12 +554,14 @@ describe("buildSentryInitOptions release", () => { beforeEach(() => { vi.stubEnv("SENTRY_RELEASE", ""); - vi.doMock("../build-commit.js", () => ({ readBuildCommit: () => commit })); + readBuildCommit.mockReturnValue(commit); + vi.doMock("../build-commit.js", () => ({ readBuildCommit })); }); afterEach(() => { vi.unstubAllEnvs(); vi.doUnmock("../build-commit.js"); + readBuildCommit.mockReset(); }); it("uses the server build commit", async () => { @@ -573,9 +576,12 @@ describe("buildSentryInitOptions release", () => { }); it("leaves an unknown build unattributed", async () => { - vi.doMock("../build-commit.js", () => ({ readBuildCommit: () => null })); + // Keep one module factory and change its return value explicitly for this + // case, rather than depending on a second factory replacing the first. + readBuildCommit.mockReturnValue(null); const { buildSentryInitOptions } = await importFreshSentry(); expect(buildSentryInitOptions("test-dsn", integrations).release).toBeUndefined(); + expect(readBuildCommit).toHaveBeenCalled(); }); }); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 4831c866e8..6b8356dd06 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -72,6 +72,7 @@ import { toolAccessService, } from "../services/tool-access.js"; import { accessService } from "../services/access.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; import { toolAccessPolicyService } from "../services/tool-access-policy.js"; import { secretService } from "../services/secrets.js"; import { @@ -361,15 +362,12 @@ function mcpSseResponse(payload: unknown): Response { } function mockToolsList(tools: unknown[]) { - return vi - .spyOn(globalThis, "fetch") - .mockResolvedValue( - mcpHttpResponse({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - result: { tools }, - }), - ); + return vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => { + const body = JSON.parse(String(init?.body)); + if (body.method === "notifications/initialized") return new Response(null, { status: 202 }); + return mcpHttpResponse({ jsonrpc: "2.0", id: body.id, + result: body.method === "initialize" ? { protocolVersion: "2025-06-18" } : { tools } }); + }); } const PUBLIC_MCP_FIXTURE_URL = "https://8.8.8.8/api/mcp"; @@ -852,12 +850,14 @@ describeEmbeddedPostgres("tool access service", () => { process.env.PAPERCLIP_TOOL_ACCESS_TEST_DATABASE_URL?.trim(); if (externalDatabaseUrl) { db = createDb(externalDatabaseUrl); + await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true }); return; } tempDb = await startEmbeddedPostgresTestDatabase( "paperclip-tool-access-service-", ); db = createDb(tempDb.connectionString); + await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true }); }, 20_000); afterEach(async () => { @@ -3382,10 +3382,10 @@ describeEmbeddedPostgres("tool access service", () => { priority: 100, selectors: { connectionId: connection.id }, }); - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => mcpHttpResponse({ jsonrpc: "2.0", - id: "paperclip-tool-test", + id: JSON.parse(String(init?.body)).id, result: { content: [{ type: "text", text: "sent" }] }, }), ); @@ -3674,10 +3674,10 @@ describeEmbeddedPostgres("tool access service", () => { expect(waiting.body.result).toBeUndefined(); // 3. Approving from the review queue is what runs the parked test call. - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => mcpHttpResponse({ jsonrpc: "2.0", - id: "paperclip-tool-test", + id: JSON.parse(String(init?.body)).id, result: { content: [{ type: "text", text: "sent" }] }, }), ); @@ -3741,10 +3741,10 @@ describeEmbeddedPostgres("tool access service", () => { .send(body) .expect(200); - vi.spyOn(globalThis, "fetch").mockResolvedValue( + vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => mcpHttpResponse({ jsonrpc: "2.0", - id: "paperclip-tool-test", + id: JSON.parse(String(init?.body)).id, result: { content: [{ type: "text", text: "sent" }] }, }), ); @@ -5101,7 +5101,7 @@ describeEmbeddedPostgres("tool access service", () => { "youcom", ]), ); - expect(res.body.apps).toHaveLength(48); + expect(res.body.apps).toHaveLength(51); expect( res.body.apps.find((app: { slug: string }) => app.slug === "gmail") .ownershipAvailability, diff --git a/server/src/__tests__/tool-gateway-service.test.ts b/server/src/__tests__/tool-gateway-service.test.ts index 111251fe71..6d8420561f 100644 --- a/server/src/__tests__/tool-gateway-service.test.ts +++ b/server/src/__tests__/tool-gateway-service.test.ts @@ -1177,9 +1177,9 @@ describeEmbeddedPostgres("tool gateway service", () => { selectors: { riskLevel: "read" }, }); const originalFetch = globalThis.fetch; - globalThis.fetch = async () => new Response(JSON.stringify({ + globalThis.fetch = async (_url, init) => new Response(JSON.stringify({ jsonrpc: "2.0", - id: "paperclip-tool-test", + id: JSON.parse(String(init?.body)).id, result: { _meta: { elicitation: { @@ -1765,9 +1765,9 @@ describeEmbeddedPostgres("tool gateway service", () => { selectors: { riskLevel: "read" }, }); const originalFetch = globalThis.fetch; - globalThis.fetch = async () => new Response(JSON.stringify({ + globalThis.fetch = async (_url, init) => new Response(JSON.stringify({ jsonrpc: "2.0", - id: "paperclip-tool-test", + id: JSON.parse(String(init?.body)).id, result: { elicitation: { message: "Need input" }, content: [] }, }), { status: 200, headers: { "content-type": "application/json" } }); try { diff --git a/server/src/__tests__/tool-gateway.test.ts b/server/src/__tests__/tool-gateway.test.ts index 9c200f79b2..92ac265675 100644 --- a/server/src/__tests__/tool-gateway.test.ts +++ b/server/src/__tests__/tool-gateway.test.ts @@ -585,10 +585,10 @@ describeEmbeddedPostgres("tool gateway acceptance", () => { it("exposes a named gateway with scoped bearer-token auth and revocation", async () => { const company = await createCompany(db); - const remote = await startFakeRemoteMcpServer(async () => ({ + const remote = await startFakeRemoteMcpServer(async ({ body }) => ({ body: { jsonrpc: "2.0", - id: "test", + id: body?.id, result: { content: [{ type: "text", text: "read ok" }], structuredContent: { ok: true } }, }, })); @@ -1127,10 +1127,10 @@ describeEmbeddedPostgres("tool gateway acceptance", () => { it("rate limits public named gateway session setup, discovery, and calls with redacted audits", async () => { const company = await createCompany(db); - const remote = await startFakeRemoteMcpServer(async () => ({ + const remote = await startFakeRemoteMcpServer(async ({ body }) => ({ body: { jsonrpc: "2.0", - id: "test", + id: body?.id, result: { content: [{ type: "text", text: "read ok" }], structuredContent: { ok: true } }, }, })); @@ -1561,7 +1561,7 @@ describeEmbeddedPostgres("tool gateway acceptance", () => { if (upstreamCalls === 1) return new Response("unauthorized", { status: 401 }); return new Response(JSON.stringify({ jsonrpc: "2.0", - id: "fixture", + id: JSON.parse(String(init.body)).id, result: { content: [{ type: "text", text: "repo-a" }], structuredContent: { repositories: ["repo-a"] } }, }), { status: 200, headers: { "content-type": "application/json" } }); }, diff --git a/server/src/redaction.ts b/server/src/redaction.ts index f475bacc8f..34ed09ca27 100644 --- a/server/src/redaction.ts +++ b/server/src/redaction.ts @@ -1,4 +1,5 @@ import { redactCommandText } from "@paperclipai/adapter-utils"; +import { isPublicExecutorToolSelector } from "@paperclipai/adapter-utils/command-redaction"; const SECRET_FIELD_NAME_PATTERN = String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|access[-_]?token|auth(?:_?token)?|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring|browser[-_]?code|login[-_]?url)[A-Za-z0-9_-]*`; @@ -735,7 +736,7 @@ function sanitizeValue(value: unknown): unknown { // string leaf after validated protocol discriminators have had a chance to // opt in above in sanitizeRecord. if (typeof value === "string") { - return JWT_VALUE_RE.test(value) + return JWT_VALUE_RE.test(value) && !isPublicExecutorToolSelector(value) ? REDACTED_EVENT_VALUE : redactSensitiveText(value); } @@ -926,6 +927,7 @@ export function sanitizeRecord( if ( typeof value === "string" && JWT_VALUE_RE.test(value) && + !isPublicExecutorToolSelector(value) && !isPaperclipSchemaDiscriminator(key, value) ) { redacted[key] = REDACTED_EVENT_VALUE; diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 34184f288e..e4a0c1b0a9 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -4,6 +4,7 @@ import { agents, companies, connectionGrants, issueThreadInteractions, toolConne import { and, eq, or } from "drizzle-orm"; import { APP_STORE_DEFINITIONS, + isRemoteMcpConnectorId, GITHUB_CONNECTOR_PROFILES, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, isAgentStatusAssignableToWork, @@ -67,6 +68,7 @@ import { paperclipCloudConnectorCapabilitiesFromEnv, } from "../services/paperclip-cloud-connector.js"; import { runtimeCanonicalOrigin } from "../services/cloud-runtime-identity.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; import { completePaperclipCloudConnectorEnrollment, loadPaperclipCloudConnectorIdentity, @@ -815,6 +817,7 @@ function connectorEnrollmentPrincipal(req: Request): string { ? await options.paperclipCloudConnector.getCapabilities() : []; const vercelConnect = vercelConnectIntegrationStatus(); + const { enableMcpAggregators } = await instanceSettingsService(db).getExperimental(); res.json({ capabilities: await describeConnectionCreateCapabilities(req, companyId), credentialSources: { @@ -830,7 +833,7 @@ function connectorEnrollmentPrincipal(req: Request): string { : "Vercel Connect setup is disabled on this Paperclip instance.", }, }, - apps: APP_STORE_DEFINITIONS.map((app) => + apps: APP_STORE_DEFINITIONS.filter((app) => enableMcpAggregators || !isRemoteMcpConnectorId(app.slug)).map((app) => appWithPaperclipCloudConnectorAvailability(app, advertisedProfiles) ), }); diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index b3d5749d86..19691b2590 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -234,6 +234,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta // continuing to accept the compatibility key in stored settings. enableApps: true, enableChatConnectors: parsed.data.enableChatConnectors ?? false, + enableMcpAggregators: parsed.data.enableMcpAggregators ?? false, enablePipelines: parsed.data.enablePipelines ?? false, enableCases: parsed.data.enableCases ?? false, enableAgentChat: parsed.data.enableAgentChat ?? false, @@ -275,6 +276,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableStreamlinedUi: true, enableApps: true, enableChatConnectors: false, + enableMcpAggregators: false, enablePipelines: false, enableCases: false, enableAgentChat: false, diff --git a/server/src/services/mcp-http.ts b/server/src/services/mcp-http.ts index 0f699018c7..3ae11aec37 100644 --- a/server/src/services/mcp-http.ts +++ b/server/src/services/mcp-http.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; // Helpers for talking to remote MCP servers over the Streamable HTTP transport. // // The MCP Streamable HTTP spec requires the client to advertise that it accepts @@ -15,6 +16,13 @@ export const MCP_HTTP_ACCEPT = "application/json, text/event-stream"; export const MCP_PROTOCOL_VERSION = "2025-06-18"; +export class McpHttpResponseError extends Error { + constructor(readonly reason: "invalid_json" | "malformed_response" | "too_large", message: string) { + super(message); + this.name = "McpHttpResponseError"; + } +} + /** * Default headers for an MCP Streamable HTTP JSON-RPC POST. Caller-supplied * headers (e.g. resolved credentials) are preserved, while the required @@ -33,6 +41,7 @@ export class McpHttpInitializationError extends Error { message: string, readonly stage: "initialize" | "initialized_notification", readonly status: number | null, + readonly response?: Response, ) { super(message); this.name = "McpHttpInitializationError"; @@ -40,9 +49,8 @@ export class McpHttpInitializationError extends Error { } /** - * Establish the short-lived Streamable HTTP session needed by stateful MCP - * servers. The returned headers belong only to the caller's next request; no - * session id is persisted with the connection or shared across operations. + * Establish a Streamable HTTP session. Callers may retain protocol headers in + * the in-memory cache scoped to the connection and effective credential identity. */ export async function initializeMcpHttpSession(input: { send: (init: RequestInit) => Promise; @@ -68,14 +76,12 @@ export async function initializeMcpHttpSession(input: { `Remote MCP initialization returned HTTP ${initializeResponse.status}`, "initialize", initializeResponse.status, + initializeResponse, ); } let payload: unknown; try { - payload = parseMcpHttpResponseBody( - await initializeResponse.text(), - initializeResponse.headers.get("content-type"), - ); + payload = await readMcpHttpResponse(initializeResponse, `${input.requestId}-initialize`); } catch { throw new McpHttpInitializationError("Remote MCP initialization returned an invalid response", "initialize", null); } @@ -83,6 +89,7 @@ export async function initializeMcpHttpSession(input: { ? (payload as { result?: unknown }).result : null; const resultRecord = result && typeof result === "object" ? result as Record : null; + if (!resultRecord) throw new McpHttpInitializationError("Remote MCP initialization failed", "initialize", null); const protocolVersion = typeof resultRecord?.protocolVersion === "string" && resultRecord.protocolVersion ? resultRecord.protocolVersion : MCP_PROTOCOL_VERSION; @@ -166,3 +173,97 @@ export function parseMcpHttpResponseBody(bodyText: string, contentType: string | if (lastError) throw lastError; throw new SyntaxError("MCP SSE response contained no data events"); } + +/** Read until the response for this request arrives, without waiting for an SSE + * connection to close. Notifications and responses for other IDs are ignored. */ +export async function readMcpHttpResponse( + response: Response, + requestId: string | number, + options: { maxBytes?: number; onRequest?: (message: Record) => Promise } = {}, +): Promise { + const maxBytes = options.maxBytes ?? 8 * 1024 * 1024; + const isStream = response.headers.get("content-type")?.toLowerCase().includes("text/event-stream"); + const reader = response.body?.getReader(); + // Injected HTTP transports can expose a buffered text response rather than a + // Web ReadableStream. Keep the same size and message-ID checks for both forms. + if (!reader) { + const body = await response.text(); + if (Buffer.byteLength(body, "utf8") > maxBytes) throw new McpHttpResponseError("too_large", "MCP response exceeded the size limit"); + return readMcpHttpResponse(new Response(body, { + headers: { "content-type": response.headers.get("content-type") ?? "application/json" }, + }), requestId, options); + } + const decoder = new TextDecoder(); + let buffer = ""; + let bytes = 0; + const parse = (text: string): unknown => { + try { return JSON.parse(text); } + catch { throw new McpHttpResponseError("invalid_json", "MCP response contained invalid JSON"); } + }; + const inspect = async (message: unknown): Promise => { + if (!message || typeof message !== "object") return undefined; + const record = message as Record; + if (record.id === requestId && ("result" in record || "error" in record)) return record; + if ("method" in record && "id" in record) await options.onRequest?.(record); + return undefined; + }; + const event = async (value: string) => { + const data = value.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).replace(/^ /, "")).join("\n"); + if (!data) return undefined; + return inspect(parse(data)); + }; + try { + while (true) { + const { value, done } = await reader.read(); + bytes += value?.byteLength ?? 0; + if (bytes > maxBytes) throw new McpHttpResponseError("too_large", "MCP response exceeded the size limit"); + buffer += decoder.decode(value, { stream: !done }); + if (isStream) { + // Normalize CRLF after concatenating chunks, including split CR/LF pairs. + buffer = buffer.replace(/\r\n/g, "\n"); + let boundary: number; + while ((boundary = buffer.indexOf("\n\n")) >= 0) { + const result = await event(buffer.slice(0, boundary)); + buffer = buffer.slice(boundary + 2); + if (result !== undefined) return result; + } + } + if (done) break; + } + const result = isStream ? await event(buffer) : await inspect(parse(buffer)); + if (result !== undefined) return result; + throw new McpHttpResponseError("malformed_response", "MCP response did not contain the requested message ID"); + } finally { + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } +} + +const sessions = new Map; expiresAt: number }>(); +const initializing = new Map>>(); +const SESSION_TTL_MS = 30 * 60_000; + +/** Cache only protocol headers; credential hashes and scope separate every + * connection and effective identity. Never cache a tool call or replay a write. */ +export async function getMcpHttpSession(input: Parameters[0] & { scope: string }) { + const key = `${input.scope}:${createHash("sha256").update(JSON.stringify(Object.entries(input.headers ?? {}).sort())).digest("hex")}`; + const cached = sessions.get(key); + if (cached && cached.expiresAt > Date.now()) return { ...input.headers, ...cached.headers }; + const pending = initializing.get(key); + if (pending) return pending; + const promise = initializeMcpHttpSession(input).then((headers) => { + if (initializing.get(key) !== promise) throw new Error("MCP connection changed while initializing; reconnect before calling tools"); + for (const [id, value] of sessions) if (value.expiresAt <= Date.now()) sessions.delete(id); + if (sessions.size >= 1000) sessions.delete(sessions.keys().next().value!); + const protocolHeaders = Object.fromEntries(Object.entries(headers).filter(([name]) => ["mcp-session-id", "mcp-protocol-version"].includes(name.toLowerCase()))); + sessions.set(key, { headers: protocolHeaders, expiresAt: Date.now() + SESSION_TTL_MS }); + return headers; + }).finally(() => { if (initializing.get(key) === promise) initializing.delete(key); }); + initializing.set(key, promise); + return promise; +} + +export function forgetMcpHttpSessions(connectionId: string) { + for (const key of sessions.keys()) if (key.startsWith(`${connectionId}:`)) sessions.delete(key); + for (const key of initializing.keys()) if (key.startsWith(`${connectionId}:`)) initializing.delete(key); +} diff --git a/server/src/services/remote-mcp-pending.ts b/server/src/services/remote-mcp-pending.ts new file mode 100644 index 0000000000..3b65bdb6ce --- /dev/null +++ b/server/src/services/remote-mcp-pending.ts @@ -0,0 +1,78 @@ +import { redactEventPayload, redactSensitiveText } from "../redaction.js"; +import { checkOAuthEndpointUrl, type ToolUpstreamPending } from "@paperclipai/shared"; + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +/** Recognize protocol/provider envelopes, never generic app-data statuses. + * Links are navigation targets kept outside durable result/audit storage. */ +export function extractRemoteMcpPending(value: unknown, provider?: string | null, toolName?: string): ToolUpstreamPending | null { + const root = record(value); + if (!root) return null; + const links = new Map(); + let pending: ToolUpstreamPending | null = null; + const addLink = (url: unknown, id?: string) => { + const checked = checkOAuthEndpointUrl(url); + if (checked.ok && links.size < 8) links.set(checked.url, { url: checked.url, host: checked.host, ...(id ? { elicitationId: id } : {}) }); + }; + const urlElicitation = (value: unknown) => { + const item = record(value); + if (item?.mode !== "url" || typeof item.elicitationId !== "string") return; + pending ??= { kind: "authorization", links: [] }; + const id = item.elicitationId.slice(0, 512); + addLink(item.url, id); + if (links.size && !pending.elicitationId) pending.elicitationId = id; + }; + if (root.method === "elicitation/create") urlElicitation(root.params); + const error = record(root.error); + const elicitations = record(error?.data)?.elicitations; + if (error?.code === -32042 && Array.isArray(elicitations)) elicitations.slice(0, 8).forEach(urlElicitation); + + const result = record(root.result) ?? root; + const payloads: Record[] = [result]; + const structured = record(result.structuredContent); + if (structured) payloads.push(structured); + if (Array.isArray(result.content)) { + for (const item of result.content.slice(0, 100)) { + const content = record(item); + if (content?.type !== "text" || typeof content.text !== "string" || content.text.length > 512_000) continue; + try { const parsed = record(JSON.parse(content.text)); if (parsed) payloads.push(parsed); } catch { /* Ordinary text. */ } + } + } + for (const payload of payloads) { + if (provider === "executor" && payload.status === "waiting_for_interaction" + && typeof payload.executionId === "string" && payload.executionId) { + const interaction = record(payload.interaction); + if (interaction?.kind !== "form" && interaction?.kind !== "url") continue; + pending = { kind: "approval", links: [], executionId: payload.executionId.slice(0, 512), resumeTool: "resume" }; + if (typeof payload.expiresAt === "string" && Number.isFinite(Date.parse(payload.expiresAt))) pending.expiresAt = payload.expiresAt; + if (typeof interaction.message === "string") pending.message = redactSensitiveText(interaction.message).slice(0, 4000); + const schema = record(interaction.requestedSchema); + if (schema) pending.requestedSchema = redactEventPayload(schema) ?? undefined; + if (interaction.kind === "url") addLink(interaction.url); + } + if (provider === "arcade" && (result.isError === true || /(?:^|[._])ManageAuthorization$/.test(toolName ?? "")) + && typeof payload.authorization_url === "string") { + addLink(payload.authorization_url); + pending ??= { kind: "authorization", links: [] }; + } + // This tool returns connection handoffs keyed by app. Other Composio tools + // may return arbitrary application data with redirect_url/status fields. + if (provider === "composio" && toolName === "COMPOSIO_MANAGE_CONNECTIONS") { + let visited = 0; + const visit = (item: unknown, depth: number) => { + if (++visited > 500 || depth > 10 || !item || typeof item !== "object") return; + const entry = record(item); + const checked = checkOAuthEndpointUrl(entry?.redirect_url); + if (checked.ok && checked.host === "connect.composio.dev" && new URL(checked.url).pathname.startsWith("/link/")) { + addLink(checked.url); + pending ??= { kind: "authorization", links: [] }; + } + for (const child of Object.values(item).slice(0, 100)) visit(child, depth + 1); + }; + visit(payload, 0); + } + } + return pending ? { ...pending, links: [...links.values()] } : null; +} diff --git a/server/src/services/tool-access-policy.ts b/server/src/services/tool-access-policy.ts index 5eb0cf2d36..e09d1b2872 100644 --- a/server/src/services/tool-access-policy.ts +++ b/server/src/services/tool-access-policy.ts @@ -1183,6 +1183,11 @@ export function toolAccessPolicyService(db: Db) { const { ctx, redaction } = loaded; const profileState = await effectiveProfiles(ctx); const effectiveProfileIds = profileState.profiles.map((profile) => profile.id); + const permittedByProfile = profileState.profiles.some((profile) => { + const matchingEntries = profileState.entries.filter((entry) => entry.profileId === profile.id && profileEntryMatches(entry, ctx)); + return !matchingEntries.some((entry) => entry.effect === "exclude") + && (profile.defaultAction === "allow" || matchingEntries.some((entry) => entry.effect === "include")); + }); const policies = await db.select().from(toolPolicies).where(and(eq(toolPolicies.companyId, ctx.companyId), eq(toolPolicies.enabled, true))).orderBy(asc(toolPolicies.priority), asc(toolPolicies.createdAt)); for (const policy of policies) { const conditions = policyConditions(policy); @@ -1276,6 +1281,9 @@ export function toolAccessPolicyService(db: Db) { return decision("allow", "allow_trust_rule", policy.description ?? "Tool access allowed by trust rule.", effectiveProfileIds, [policy.id], { redactionPlan: redaction.redactionPlan, policyExplanation }); } if (policy.policyType === "require_approval") { + // The connection's Ask first control restricts an existing action grant; + // it must never grant access to agents outside that connection's profile. + if (policy.config?.source === "app_gallery_finish" && !permittedByProfile) continue; return decision("require_approval", "requires_approval_policy", policy.description ?? "Tool access requires approval.", effectiveProfileIds, [policy.id], { redactionPlan: redaction.redactionPlan, policyExplanation }); } if (policy.policyType === "allow") { diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index e48dbec1cd..bf07307ce0 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -1,4 +1,5 @@ -import { connectionPurposeTransportSchema } from "@paperclipai/shared"; +import { isRemoteMcpConnectorMethod, connectionPurposeTransportSchema } from "@paperclipai/shared"; +import { instanceSettingsService } from "./instance-settings.js"; import { syncConnectionCredentialBindings } from "./connection-credential-bindings.js"; import { canBrowseProjectRepositoryGrant, mergeProjectRepository } from "./project-repositories.js"; import { captureRunIdentity } from "./run-identity.js"; @@ -186,6 +187,10 @@ import { logger } from "../middleware/logger.js"; import { logActivity } from "./activity-log.js"; import { initializeMcpHttpSession, + McpHttpInitializationError, + getMcpHttpSession, + forgetMcpHttpSessions, + readMcpHttpResponse, mcpHttpRequestHeaders, parseMcpHttpResponseBody, } from "./mcp-http.js"; @@ -2316,6 +2321,17 @@ function normalizedProviderToolName(toolName: string): string { .replace(/[:._-]+/g, "-"); } +// Aggregators can add arbitrarily powerful tools without changing their MCP +// endpoint. Only these reviewed, exact capabilities are read-only. Unknown or +// renamed actions remain enabled by the usual access rules, but have write risk. +// Legacy Composio child connections do not use these gallery template keys. +const AGGREGATOR_READ_TOOLS = new Map>([ + ["executor", new Set(["skills"])], + ["composio", new Set(["COMPOSIO_SEARCH_TOOLS", "COMPOSIO_GET_TOOL_SCHEMAS", "COMPOSIO_SEARCH_SKILLS", "COMPOSIO_USE_SKILL"])], + ["arcade", new Set(["Github.GetRepository"])], + ["zapier", new Set()], +]); + export function classifyRisk( tool: McpToolDescriptor, sourceTemplateKey?: string | null, @@ -2324,6 +2340,12 @@ export function classifyRisk( if (annotations.destructiveHint === true || annotations.destructive === true) return "destructive"; const normalizedToolName = normalizedProviderToolName(tool.name); + const reviewedReads = AGGREGATOR_READ_TOOLS.get(sourceTemplateKey ?? ""); + if (reviewedReads) { + if (verbMatches(tool.name, "delete|remove|destroy|unpublish")) return "destructive"; + if (annotations.readOnlyHint === false || annotations.writeHint === true) return "write"; + return reviewedReads.has(tool.name) ? "read" : "write"; + } if (sourceTemplateKey === "railway") { const reviewed = railwayRisk(normalizedToolName); return reviewed === "read" && (annotations.readOnlyHint === false || annotations.writeHint === true) ? "write" : reviewed; @@ -6068,6 +6090,7 @@ export function toolAccessService( removalOptions: { confirmComposioChildren?: boolean } = {}, ): Promise { const connection = await getConnectionRow(connectionId, companyId); + forgetMcpHttpSessions(connection.id); const now = new Date(); const binding = actorBinding(actor); @@ -6765,31 +6788,26 @@ export function toolAccessService( // Pinned to the address the guard approved: `config.url` is operator-supplied, // so a second DNS resolution here would reopen the rebinding window that // PAP-17098 closed for the OAuth endpoints. - const listRequestBody = JSON.stringify({ - jsonrpc: "2.0", - id: "paperclip-catalog-refresh", - method: "tools/list", - params: {}, - }); - const sendRemote = (init: RequestInit) => - requestRemoteHttpEndpoint(new URL(endpoint), init); - const sendToolsList = (requestHeaders: Record) => - sendRemote({ - method: "POST", - // MCP Streamable HTTP requires advertising that we accept both a JSON body - // and an SSE stream; spec-compliant servers 406 without it (see mcp-http.ts). - headers: mcpHttpRequestHeaders(requestHeaders), - body: listRequestBody, - }); + let listRequestId = "paperclip-catalog-refresh"; + let sessionHeaders = headers; + const sendRemote = (init: RequestInit) => requestRemoteHttpEndpoint(new URL(endpoint), init); + const sendToolsList = (requestHeaders: Record, cursor?: string) => { + sessionHeaders = requestHeaders; + return sendRemote({ method: "POST", headers: mcpHttpRequestHeaders(requestHeaders), + body: JSON.stringify({ jsonrpc: "2.0", id: listRequestId, method: "tools/list", params: cursor ? { cursor } : {} }) }); + }; let usedInitializedSession = connection.config.mcpSessionRequired === true; let response: Response; if (usedInitializedSession) { - const sessionHeaders = await initializeMcpHttpSession({ - send: sendRemote, - headers, - requestId: "paperclip-catalog-refresh", - }); - response = await sendToolsList(sessionHeaders); + try { + sessionHeaders = await getMcpHttpSession({ send: sendRemote, headers, + scope: `${connection.id}:catalog:${actor?.actorType}:${actor?.actorId}:${endpoint}`, + requestId: listRequestId }); + response = await sendToolsList(sessionHeaders); + } catch (error) { + if (!(error instanceof McpHttpInitializationError) || !error.response) throw error; + response = error.response; + } } else { response = await sendToolsList(headers); // `tools/list` is read-only, so a 400 can safely be retried after the MCP @@ -6810,6 +6828,17 @@ export function toolAccessService( } } } + if (response.status === 404 && new Headers(sessionHeaders).has("mcp-session-id")) { + // MCP uses 404 for an expired server session. Discovery is read-only, so + // discard the stale session and retry it once with a new handshake. + forgetMcpHttpSessions(connection.id); + await response.body?.cancel().catch(() => undefined); + sessionHeaders = await getMcpHttpSession({ send: sendRemote, headers, + scope: `${connection.id}:catalog:${actor?.actorType}:${actor?.actorId}:${endpoint}`, + requestId: listRequestId }); + response = await sendToolsList(sessionHeaders); + if (response.status === 404) forgetMcpHttpSessions(connection.id); + } if ( usedInitializedSession && connection.config.mcpSessionRequired !== true @@ -6833,6 +6862,8 @@ export function toolAccessService( const refreshed = await composioSessions.ensureSession(connection.id, { force: true, }); + listRequestId = "paperclip-catalog-refresh-retry"; + sessionHeaders = refreshed.headers; response = await requestRemoteHttpEndpoint(new URL(refreshed.url), { method: "POST", headers: mcpHttpRequestHeaders(refreshed.headers), @@ -6954,20 +6985,23 @@ export function toolAccessService( status: response.status, }); } - const payload = parseMcpHttpResponseBody( - await response.text(), - response.headers.get("content-type"), - ); - const result = asRecord(asRecord(payload).result); - const payloadTools = asRecord(payload).tools; - const tools: unknown[] = Array.isArray(result.tools) - ? result.tools - : Array.isArray(payloadTools) - ? payloadTools - : []; - const descriptors = tools - .map((tool) => normalizeToolDescriptor(tool)) - .filter((tool): tool is McpToolDescriptor => Boolean(tool)); + const descriptors: McpToolDescriptor[] = []; + const seenCursors = new Set(); + for (let page = 0; ; page += 1) { + const payload = await readMcpHttpResponse(response, listRequestId); + const record = asRecord(payload); + if (record.error) throw new HttpError(502, "Remote MCP tool discovery failed", { code: "mcp_catalog_error" }); + const result = asRecord(record.result); + if (!Array.isArray(result.tools)) throw new HttpError(502, "Remote MCP returned an invalid tool catalog", { code: "mcp_catalog_invalid" }); + descriptors.push(...result.tools.map((tool) => normalizeToolDescriptor(tool)).filter((tool): tool is McpToolDescriptor => Boolean(tool))); + const cursor = typeof result.nextCursor === "string" ? result.nextCursor : null; + if (!cursor) break; + if (seenCursors.has(cursor) || page >= 99 || descriptors.length > 20_000) throw new HttpError(502, "Remote MCP tool catalog pagination did not finish", { code: "mcp_catalog_pagination" }); + seenCursors.add(cursor); + listRequestId = `paperclip-catalog-refresh-${page + 1}`; + response = await sendToolsList(sessionHeaders, cursor); + if (!response.ok) throw new HttpError(502, "Remote MCP catalog page could not be read", { status: response.status }); + } if (!isRailwayConnection(connection)) return descriptors; if (descriptors.some((tool) => normalizeRailwayToolName(tool.name).startsWith(RAILWAY_TOOL_PREFIX))) { throw unprocessable("Railway advertised a reserved Paperclip action name. Refresh is blocked pending review.", { code: "railway_tool_name_collision" }); @@ -7015,7 +7049,7 @@ export function toolAccessService( connection: typeof toolConnections.$inferSelect, ): boolean { return ( - asRecord(connection.config).sourceTemplateKey === COMPOSIO_GALLERY_KEY + asRecord(connection.config).sourceTemplateKey === COMPOSIO_GALLERY_KEY && connection.transport === "rest_api" ); } @@ -7772,6 +7806,13 @@ export function toolAccessService( const existingByName = new Map( existingRows.map((entry) => [entry.toolName, entry]), ); + if (isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) { + const discoveredNames = new Set(descriptors.map((descriptor) => descriptor.name)); + const removedIds = existingRows.filter((entry) => !discoveredNames.has(entry.toolName) && entry.status !== "disabled").map((entry) => entry.id); + if (removedIds.length) await db.update(toolCatalogEntries) + .set({ status: "disabled", quarantineReason: "mcp_tool_removed", updatedAt: refreshedAt }) + .where(and(eq(toolCatalogEntries.companyId, connection.companyId), eq(toolCatalogEntries.connectionId, connection.id), inArray(toolCatalogEntries.id, removedIds))); + } // Retired native actions are absent from discovery, but old catalog rows // still need to show as disabled. Gateway denial also applies before refresh. const blockedRailwayEntryIds = isRailwayEndpoint(connection.config.url) @@ -7835,7 +7876,7 @@ export function toolAccessService( ? "disabled" : shouldQuarantine ? "quarantined" - : existing?.status === "disabled" + : existing?.status === "disabled" && existing.quarantineReason !== "mcp_tool_removed" ? "disabled" : quarantineOnRefresh && existing?.status === "quarantined" ? "quarantined" @@ -7950,10 +7991,12 @@ export function toolAccessService( const activeEntries = updatedEntries.filter( (entry) => entry.status === "active", ); - if (!refreshOptions.skipDefaultProfileSync) { + const preserveMcpAccess = connection.config.mcpPreserveAccess === true + && isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey); + if (!refreshOptions.skipDefaultProfileSync || preserveMcpAccess) { await enableCatalogEntriesByDefault({ connection: updatedConnection, - newCatalogEntryIds: refreshOptions.enableAllByDefault + newCatalogEntryIds: refreshOptions.enableAllByDefault && !isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey) ? activeEntries.map((entry) => entry.id) : activeEntries .filter((entry) => { @@ -7962,7 +8005,7 @@ export function toolAccessService( }) .map((entry) => entry.id), activeCatalogEntryIds: activeEntries.map((entry) => entry.id), - restoreDraftDefaults: refreshOptions.restoreDraftDefaults, + restoreDraftDefaults: refreshOptions.restoreDraftDefaults || preserveMcpAccess, actor, }); } @@ -12231,6 +12274,15 @@ export function toolAccessService( return `${base.slice(0, 151).trimEnd()} (${randomUUID().slice(0, 6)})`; } + async function assertMcpAggregatorSetupEnabled(provider: unknown, method: unknown) { + if (isRemoteMcpConnectorMethod(provider, method) + && !(await instanceSettingsService(db).getExperimental()).enableMcpAggregators) { + throw forbidden("Enable MCP aggregators in Settings → Experimental to set up this connection", { + code: "mcp_aggregators_disabled", + }); + } + } + async function connectGalleryApp( companyId: string, input: ConnectToolApp, @@ -12377,18 +12429,20 @@ export function toolAccessService( const method = galleryEntry ? connectionMethodFor(galleryEntry, inferredMethodKey) : null; + const remoteMcpConnector = isRemoteMcpConnectorMethod(galleryEntry?.slug, method?.key); + await assertMcpAggregatorSetupEnabled(galleryEntry?.slug, method?.key); if (galleryEntry && input.link) { const acceptsProviderGeneratedUrl = method?.transport === "mcp_remote" && method.auth === "none" && !method.defaults?.serverUrl && !method.defaults?.serverUrlTemplate; - if (!acceptsProviderGeneratedUrl) { + if (!acceptsProviderGeneratedUrl && !remoteMcpConnector) { throw badRequest( `${galleryEntry.name} does not accept a provider-generated connection URL`, ); } - if (!getAppDefinitionForUrl(input.link, [galleryEntry])) { + if (!(remoteMcpConnector && galleryEntry.slug === "executor") && !getAppDefinitionForUrl(input.link, [galleryEntry])) { throw badRequest( `That connection URL does not belong to ${galleryEntry.name}`, ); @@ -12699,6 +12753,7 @@ export function toolAccessService( transport === "mcp_remote" ? { url: + (remoteMcpConnector ? remoteUrlCredential?.publicUrl : undefined) ?? normalizedMethodConfig?.url ?? method?.defaults?.serverUrl ?? remoteUrlCredential?.publicUrl ?? @@ -12716,6 +12771,11 @@ export function toolAccessService( // the wizard projects the app's action defaults into policies at // finish time instead of using catalog quarantine as access state. quarantineNewEntries: galleryEntry.slug === "railway", + ...(remoteMcpConnector ? { + mcpSessionRequired: true, + mcpAuthMode: input.authMode ?? "auto", + mcpPreserveAccess: Boolean(retainedConnection && (retainedConnection.status === "active" || asRecord(retainedConnection.config).mcpPreserveAccess === true)), + } : {}), ...(galleryEntry.slug === "posthog" ? { safeDefault: true } : {}), } : { ...baseConfig, quarantineNewEntries: false, unverifiedServer: true }; @@ -12734,7 +12794,7 @@ export function toolAccessService( config.quarantineNewEntries = true; } const acceptsCustomerOAuthClient = - method?.auth === "oauth" && method.ownershipModes.includes("customer"); + remoteMcpConnector || (method?.auth === "oauth" && method.ownershipModes.includes("customer")); if (galleryEntry && input.oauthClient && !acceptsCustomerOAuthClient) { throw badRequest( `${galleryEntry.name} does not accept customer-owned OAuth client credentials`, @@ -12784,7 +12844,7 @@ export function toolAccessService( // operator supplied and is upgraded to `oauth` when discovery proves the // endpoint needs sign-in (see `remoteTools` and `startOAuth`). const genericAuthKind: ToolConnectionAuthKind = - method?.auth ?? + (remoteMcpConnector ? undefined : method?.auth) ?? (input.authMode === "oauth" || input.oauthClient ? "oauth" : input.authMode === "bearer" || input.authMode === "custom_headers" @@ -12834,7 +12894,8 @@ export function toolAccessService( previousGrantKind === requestedGrantKind && galleryEntry && retainedSource === galleryEntry.slug && - retainedMethodKey === method?.key, + retainedMethodKey === method?.key && + (!remoteMcpConnector || (baseConfig.url === retainedConfig.url && genericAuthKind === retainedConnection.authKind)), ); const retainedCredentialSecretRefs = canRetainCredentialMaterial ? (retainedPersonalIdentity?.grant?.credentialSecretRefs ?? @@ -12866,9 +12927,9 @@ export function toolAccessService( const credentialFields = credentialSource === "vercel_connect" ? [] - : galleryEntry + : galleryEntry && !remoteMcpConnector ? credentialFieldsFor(galleryEntry, method?.key) - : linkCredentialFields(credentialValues); + : linkCredentialFields({ ...Object.fromEntries(retainedCredentialSecretRefs.filter((ref) => ref.configPath.startsWith("headers.") || ref.configPath === "credentials.authorization").map((ref) => [ref.configPath, "retained"])), ...credentialValues }); for (const field of credentialFields) { const value = credentialValues[field.configPath]; const retainedSecretRef = retainedCredentialSecretRefs.find( @@ -12923,6 +12984,13 @@ export function toolAccessService( } } + if (!remoteUrlCredential?.secretUrl && canRetainCredentialMaterial && baseConfig.url === retainedConfig.url) { + const retainedUrl = retainedCredentialSecretRefs.find((ref) => ref.configPath === REMOTE_URL_SECRET_CONFIG_PATH); + if (retainedUrl) { + credentialSecretRefs.push(retainedUrl); + credentialRefs.push({ name: REMOTE_URL_SECRET_CONFIG_PATH, secretId: retainedUrl.secretId, version: retainedUrl.versionSelector ?? "latest", placement: "url", key: "url", prefix: null }); + } + } if (remoteUrlCredential?.secretUrl) { const secret = await secrets.create( companyId, @@ -13072,6 +13140,7 @@ export function toolAccessService( const connectionCredentialSecretRefs = personalIdentityUserId || dedicatedAgentId ? [] : credentialSecretRefs; if (revivedConnectionPrevious) { + forgetMcpHttpSessions(revivedConnectionPrevious.id); [connectionRow] = await db .update(toolConnections) .set({ @@ -13292,6 +13361,10 @@ export function toolAccessService( ); await ensureRuntimeSlot(connectionRow); + if (input.saveDraft && remoteMcpConnector) { + return { connectionId: connectionRow.id, application: toApplication(applicationRow), connection: toConnection(connectionRow), + catalog: [], actions: { readOnly: [], canMakeChanges: [] }, suggestedDefaults: { access: "all_agents", askFirstRiskLevels: [] } }; + } if (galleryEntry && method?.auth === "oauth") { const suggestedDefaults = recommendedDefaultsForApp( galleryEntry, @@ -13317,7 +13390,7 @@ export function toolAccessService( // empty personal grant below so later catalog refreshes use the same // identity policy. const unauthenticatedPersonalProbe = Boolean( - !galleryEntry && + (!galleryEntry || remoteMcpConnector) && genericAuthKind === "none" && credentialSecretRefs.length === 0 && personalIdentityUserId && @@ -13330,7 +13403,10 @@ export function toolAccessService( }); } catch (error) { if ( - !galleryEntry && + (!galleryEntry || remoteMcpConnector) && + input.authMode !== "none" && + input.authMode !== "bearer" && + input.authMode !== "custom_headers" && error instanceof HttpError && asRecord(error.details).code === "oauth_challenge" ) { @@ -13421,7 +13497,7 @@ export function toolAccessService( // later fails: another retry may already be using the committed grant. personalPublicSetupEstablished = true; } - if (galleryEntry?.slug === COMPOSIO_GALLERY_KEY) { + if (galleryEntry?.slug === COMPOSIO_GALLERY_KEY && transport === "rest_api") { const [application] = await db .select() .from(toolApplications) @@ -13440,7 +13516,7 @@ export function toolAccessService( } const restoreDraftDefaults = Boolean(revivedConnectionPrevious); const refreshOptions = { - enableAllByDefault: restoreDraftDefaults, + enableAllByDefault: restoreDraftDefaults && !remoteMcpConnector, restoreDraftDefaults, }; const catalogConnectionId = connectionRow.id; @@ -13802,6 +13878,23 @@ export function toolAccessService( const connection = await getConnectionRow(connectionId, companyId); if (connection.status === "archived") throw conflict("Archived app connections cannot be finished"); + if (connection.config.mcpPreserveAccess === true && isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) { + const [profile] = await db.select().from(toolProfiles).where(and( + eq(toolProfiles.companyId, companyId), eq(toolProfiles.profileKey, `app:${connection.id}`), + )).limit(1); + if (!profile) throw conflict("The saved connection permissions could not be found"); + const config = { ...connection.config }; + delete config.mcpPreserveAccess; + const updated = await db.transaction(async (tx) => { + const [row] = await tx.update(toolConnections).set({ config, transportConfig: config, status: "active", enabled: true, updatedAt: new Date() }) + .where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, companyId))).returning(); + await tx.update(toolApplications).set({ status: "active", updatedAt: new Date() }).where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, companyId))); + return row; + }); + const details = await profileDetails(profile.id, companyId); + const policies = (await db.select().from(toolPolicies).where(eq(toolPolicies.companyId, companyId))).filter((policy) => asRecord(policy.config).connectionId === connection.id); + return { connection: toConnection(updated), profile: toProfile(profile), profileEntries: details.entries, profileBindings: details.bindings, policies: policies.map(toPolicy) }; + } const enabledIds = [ ...new Set([ ...input.enabledCatalogEntryIds, @@ -13825,7 +13918,7 @@ export function toolAccessService( connection.id, input.askFirstCatalogEntryIds, ); - if (enabledRows.some((entry) => entry.status === "disabled")) { + if (enabledRows.some((entry) => entry.status === "disabled" && !(entry.quarantineReason === "mcp_tool_removed" && isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)))) { throw badRequest("Disabled actions cannot be enabled"); } if (reviewedIds.length > 0) { @@ -14032,6 +14125,28 @@ export function toolAccessService( } const profileBindings: ToolProfileBinding[] = []; + // These connectors expose one agent-access choice. Commit installation + // reach and permission bindings together, including an empty selection. + if (isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) { + const existingInstalls = await tx.select().from(toolConnectionInstalls).where(and( + eq(toolConnectionInstalls.companyId, companyId), eq(toolConnectionInstalls.connectionId, connection.id), + )); + const desired = new Map(bindingInputs.flatMap((binding) => binding.targetType === "company" || binding.targetType === "agent" + ? [[`${binding.targetType}:${binding.targetId}`, { targetType: binding.targetType, targetId: binding.targetId }] as const] : [])); + const removed = existingInstalls.filter((install) => !desired.has(`${install.targetType}:${install.targetId}`)); + const added = [...desired.values()].filter((binding) => !existingInstalls.some((install) => install.targetType === binding.targetType && install.targetId === binding.targetId)); + if (removed.length) await tx.delete(toolConnectionInstalls).where(inArray(toolConnectionInstalls.id, removed.map((install) => install.id))); + if (added.length) await tx.insert(toolConnectionInstalls).values(added.map((binding) => ({ + companyId, connectionId: connection.id, targetType: binding.targetType, targetId: binding.targetId, + createdByAgentId: actor?.actorType === "agent" ? (actor.actorId ?? null) : null, + createdByUserId: actor?.actorType === "user" ? (actor.actorId ?? null) : null, + }))); + if (added.length || removed.length) await tx.insert(toolAccessAuditEvents).values({ + companyId, connectionId: connection.id, actorType: actor?.actorType ?? "system", actorId: actor?.actorId ?? null, + action: "connection_installs.changed", outcome: "success", reasonCode: "installs_changed", + details: { added: added.map(({ targetType, targetId }) => ({ targetType, targetId })), removed: removed.map(({ targetType, targetId }) => ({ targetType, targetId })) }, + }); + } for (const bindingInput of bindingInputs) { const [binding] = await tx .insert(toolProfileBindings) @@ -14094,6 +14209,7 @@ export function toolAccessService( eq(toolCatalogEntries.companyId, companyId), inArray(toolCatalogEntries.id, enabledIds), ne(toolCatalogEntries.status, "quarantined"), + ne(toolCatalogEntries.status, "disabled"), ), ); } @@ -14228,6 +14344,7 @@ export function toolAccessService( actor?: ActorInfo, ): Promise { const connection = await getConnectionRow(connectionId, companyId); + await assertMcpAggregatorSetupEnabled(connection.config.sourceTemplateKey, connection.config.connectionMethodKey); if (connection.status === "archived") throw conflict("Archived app connections cannot be reconnected"); if (connection.credentialSource === "vercel_connect") { @@ -14409,6 +14526,7 @@ export function toolAccessService( }, ): Promise { let connection = await getConnectionRow(connectionId, companyId); + await assertMcpAggregatorSetupEnabled(connection.config.sourceTemplateKey, connection.config.connectionMethodKey); if (connection.status === "archived") throw conflict("Archived app connections cannot start sign in"); const sourceTemplateKey = @@ -15143,10 +15261,11 @@ export function toolAccessService( : suggestedAgentIds.length > 0 ? { agentIds: suggestedAgentIds } : "all_agents"; + const remoteMcpAccess = isRemoteMcpConnectorMethod(input.connection.config.sourceTemplateKey, input.connection.config.connectionMethodKey); const access: FinishToolApp["access"] = deferTaskAccess ? { agentIds: [] } : installs.length === 0 - ? normalizedSuggestedAccess + ? remoteMcpAccess ? { agentIds: [] } : normalizedSuggestedAccess : companyInstall ? "all_agents" : { agentIds }; @@ -15181,7 +15300,7 @@ export function toolAccessService( }, input.actor, ); - if (!deferTaskAccess && installs.length === 0) { + if (!deferTaskAccess && !remoteMcpAccess && installs.length === 0) { const installTargets = access === "all_agents" ? [ @@ -16856,6 +16975,7 @@ export function toolAccessService( if (!app || app.availability?.available === false) throw notFound("App not found"); const method = connectionMethodFor(app, methodKey); + await assertMcpAggregatorSetupEnabled(app.slug, method.key); if (method.transport !== "mcp_remote" || !method.defaults?.serverUrl) { throw unprocessable( "This app method does not use a hosted remote MCP endpoint", diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 9c0c67e85c..cdb44da236 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -1,6 +1,7 @@ import { runIdentityContexts } from "@paperclipai/db"; import { captureRunIdentity } from "./run-identity.js"; import { resolveManagedGitHubIdentitySelection } from "./git-credentials.js"; +import { extractRemoteMcpPending } from "./remote-mcp-pending.js"; import { logger } from "../middleware/logger.js"; import { spawn } from "node:child_process"; import { createHash, randomBytes, randomUUID } from "node:crypto"; @@ -63,6 +64,7 @@ import type { ToolAccessDecision, ToolAccessDecisionInput, ToolConnectionTestCallStatus, + ToolUpstreamPending, ToolConnectionTestCallStatusPhase, ToolCredentialSecretRef, ToolMcpGateway, @@ -89,6 +91,10 @@ import { railwayCommandBudgetMs, createRailwayClient, isRailwayConnection, isRai import { RAILWAY_SSH_SECRET_PATH, runRailwaySshCommand } from "./railway-ssh.js"; import { initializeMcpHttpSession, + getMcpHttpSession, + forgetMcpHttpSessions, + readMcpHttpResponse, + McpHttpResponseError, mcpHttpRequestHeaders, parseMcpHttpResponseBody, } from "./mcp-http.js"; @@ -1049,6 +1055,28 @@ export function createToolGatewayService( now?: () => number; } = {}, ) { + // Authorization links can contain one-time codes. Keep them briefly in memory; + // persist only the redacted request and execution identifiers for recovery. + const upstreamHandoffs = new Map(); + async function retainUpstreamHandoff(invocationId: string, pending: ToolUpstreamPending) { + for (const [id, value] of upstreamHandoffs) if (value.expires <= Date.now()) upstreamHandoffs.delete(id); + while (upstreamHandoffs.size >= 256) upstreamHandoffs.delete(upstreamHandoffs.keys().next().value!); + upstreamHandoffs.set(invocationId, { pending, expires: Date.now() + 15 * 60_000 }); + const summary = validateToolContent({ + value: { upstreamPending: { ...pending, links: [] } }, direction: "result", sensitiveMode: "redact", promptInjectionMode: "ignore", + }).summary; + await db.update(toolInvocations).set({ resultSummary: summary }).where(eq(toolInvocations.id, invocationId)); + } + function recoverUpstreamHandoff(invocation: typeof toolInvocations.$inferSelect): ToolUpstreamPending | undefined { + if (invocation.errorCode !== "provider_interaction_required") return undefined; + const cached = upstreamHandoffs.get(invocation.id); + if (cached && cached.expires > Date.now()) return cached.pending; + upstreamHandoffs.delete(invocation.id); + const stored = asRecord(storedInvocationResult(invocation)); + const pending = asRecord(stored?.upstreamPending); + if (!pending || !["approval", "authorization"].includes(String(pending.kind))) return undefined; + return { ...pending, links: [] } as unknown as ToolUpstreamPending; + } const runtimeSupervisor = createToolRuntimeSupervisor(db, { deploymentMode: options.deploymentMode, deploymentExposure: options.deploymentExposure, @@ -5843,7 +5871,8 @@ export function createToolGatewayService( } let requestHeaders = headers; if (connection.config.mcpSessionRequired === true) { - requestHeaders = await initializeMcpHttpSession({ + requestHeaders = await getMcpHttpSession({ + scope: `${connection.id}:grant:${grant.id}:actor:${session.agentId}:${endpoint}`, send: (init) => dispatchRemote(endpoint, { ...init, @@ -6004,7 +6033,32 @@ export function createToolGatewayService( headers: mcpHttpRequestHeaders(headers), }); } - const body = await readBoundedRemoteResponse(response); + const sessionExpired = response.status === 404 && new Headers(requestHeaders).has("mcp-session-id"); + if (sessionExpired) { + // The next explicit call initializes again. Never replay a tools/call + // automatically: the failed call may have changed app data. + forgetMcpHttpSessions(connection.id); + } + const body = response.ok + ? JSON.stringify(await readMcpHttpResponse(response, requestId, { + maxBytes: MAX_REMOTE_MCP_RESPONSE_BYTES, + onRequest: async (message) => { + const pending = extractRemoteMcpPending(message); + if (pending) { + await retainUpstreamHandoff(invocationId, pending); + // Defer this interaction to the user. Do not claim that consent + // was granted or repeat a potentially state-changing tool call. + await dispatchRemote(endpoint, { + method: "POST", headers: mcpHttpRequestHeaders(requestHeaders), + body: JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { action: "cancel" } }), + }); + throw new ToolGatewayHttpError(409, "This tool needs authorization in the provider.", "provider_interaction_required", { upstreamPending: pending, invocationId }); + } + const request = extractMcpElicitationRequest(message); + if (request) await requestElicitationForRecordedToolCall({ session, tool, invocationId, request }); + }, + })) + : await readBoundedRemoteResponse(response); execution.response = { httpStatus: response.status, contentType: response.headers.get("content-type"), @@ -6015,17 +6069,18 @@ export function createToolGatewayService( response.headers.get("traceparent"), }; if (!response.ok) { - await markRemoteConnectionHealth( - connection, - "error", - "Remote MCP server returned an HTTP error.", - ); + // Session expiration is recoverable on an explicit retry. Marking the + // connection unhealthy here would hide every tool and prevent it. + if (!sessionExpired) { + await markRemoteConnectionHealth(connection, "error", "Remote MCP server returned an HTTP error."); + } throw new ToolGatewayHttpError( 502, - "Remote MCP server returned an HTTP error", + sessionExpired ? "Remote MCP session expired. Retry the action explicitly to start a new session." : "Remote MCP server returned an HTTP error", "mcp_remote_status", { status: response.status, + ...(sessionExpired ? { sessionExpired: true } : {}), connectionId: connection.id, catalogEntryId: entry.id, execution, @@ -6034,10 +6089,7 @@ export function createToolGatewayService( } let payload: unknown; try { - payload = parseMcpHttpResponseBody( - body, - response.headers.get("content-type"), - ); + payload = JSON.parse(body); } catch { await markRemoteConnectionHealth( connection, @@ -6057,6 +6109,11 @@ export function createToolGatewayService( } const payloadRecord = asRecord(payload); if (!payloadRecord) throw malformedRemoteMcpResponse(); + const upstreamPending = extractRemoteMcpPending(payloadRecord, String(connection.config.sourceTemplateKey ?? ""), entry.toolName); + if (upstreamPending) { + await retainUpstreamHandoff(invocationId, upstreamPending); + throw new ToolGatewayHttpError(409, "Complete the provider's authorization or approval before continuing. The original call has not been replayed.", "provider_interaction_required", { upstreamPending, invocationId }); + } const topLevelElicitation = extractMcpElicitationRequest(payloadRecord); if (topLevelElicitation) { await requestElicitationForRecordedToolCall({ @@ -6117,6 +6174,15 @@ export function createToolGatewayService( ); return { result, headerSummary, execution }; } catch (error) { + if (error instanceof McpHttpResponseError) { + const failure = error.reason === "too_large" ? responseTooLargeError() + : error.reason === "malformed_response" ? malformedRemoteMcpResponse() + : new ToolGatewayHttpError(502, "Remote MCP server returned invalid JSON", "mcp_remote_invalid_json"); + await markRemoteConnectionHealth(connection, "error", failure.message); + throw new ToolGatewayHttpError(failure.status, failure.message, failure.reasonCode, { + connectionId: connection.id, catalogEntryId: entry.id, execution, + }); + } if (error instanceof RailwayError) { throw new ToolGatewayHttpError(error.status, error.message, error.code, { connectionId: connection.id, catalogEntryId: entry.id, execution }); } @@ -7181,6 +7247,9 @@ export function createToolGatewayService( decision: "allowed" as const, invocationId: args.invocationId, error: { message, reasonCode }, + ...(err instanceof ToolGatewayHttpError && err.reasonCode === "provider_interaction_required" + ? { upstreamPending: err.details.upstreamPending as ToolUpstreamPending } + : {}), }; } } @@ -7977,12 +8046,14 @@ export function createToolGatewayService( "executing", "rejected", "executed", + "failed", ]), ), ) .orderBy(desc(toolActionRequests.createdAt)) .limit(1); if (!match) return null; + if (match.actionRequest.status === "failed" && match.invocation.errorCode !== "provider_interaction_required") return null; // The gateway builds an ask-first request in two steps inside one call: it // inserts the row with a null signature and a null expiry, then signs the // row and sets the expiry. A concurrent matching call can observe the row in @@ -8041,6 +8112,14 @@ export function createToolGatewayService( const match = await matchingAgentActionRequest(input); if (!match) return null; const { actionRequest, invocation } = match; + if (actionRequest.status === "failed") { + throw new ToolGatewayHttpError(409, + "The provider is waiting for authorization or approval. Continue the existing execution; do not repeat the original call.", + "provider_interaction_required", { + invocationId: invocation.id, actionRequestId: actionRequest.id, + upstreamPending: recoverUpstreamHandoff(invocation), + }); + } if (actionRequest.status === "pending") { await throwApprovalRequired({ invocationId: invocation.id, @@ -8126,7 +8205,7 @@ export function createToolGatewayService( phase = "expired"; } else if ( actionRequest.status === "approved" || - actionRequest.status === "executed" + actionRequest.status === "executed" || actionRequest.status === "failed" ) { phase = invocationDone ? "done" : "running"; } else { @@ -8200,6 +8279,7 @@ export function createToolGatewayService( parameters, ...(result !== undefined ? { result } : {}), ...(error ? { error } : {}), + ...(recoverUpstreamHandoff(invocation) ? { upstreamPending: recoverUpstreamHandoff(invocation) } : {}), durationMs, requestedAt: actionRequest.createdAt.toISOString(), resolvedAt: actionRequest.resolvedAt diff --git a/tests/storybook-visual/remote-mcp-connections.config.ts b/tests/storybook-visual/remote-mcp-connections.config.ts new file mode 100644 index 0000000000..041b689c72 --- /dev/null +++ b/tests/storybook-visual/remote-mcp-connections.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: ".", testMatch: "remote-mcp-connections.spec.ts", workers: 1, fullyParallel: false, + timeout: 180_000, retries: 0, outputDir: "./test-results/remote-mcp", reporter: [["list"]], + use: { browserName: "chromium", baseURL: "http://127.0.0.1:6138", reducedMotion: "reduce", actionTimeout: 10_000, trace: "retain-on-failure" }, + webServer: { command: "node ../../scripts/serve-storybook-static.mjs --port 6138", url: "http://127.0.0.1:6138/index.json", reuseExistingServer: false }, +}); diff --git a/tests/storybook-visual/remote-mcp-connections.spec.ts b/tests/storybook-visual/remote-mcp-connections.spec.ts new file mode 100644 index 0000000000..55ae7ba165 --- /dev/null +++ b/tests/storybook-visual/remote-mcp-connections.spec.ts @@ -0,0 +1,200 @@ +import { test, expect, type Page } from "@playwright/test"; + +const providers = ["zapier", "arcade", "composio", "executor"] as const; +const go = async (page: Page, provider: string, story: string) => { + await page.goto(`/iframe.html?id=apps-connections-${provider}--${story}&viewMode=story`); + await expect(page.locator(`[data-remote-mcp-provider="${provider}"]`)).toBeVisible(); +}; + +for (const provider of providers) { + test(`${provider}: setup finishes at discovery; regular permissions, testing and lifecycle`, async ({ page, context }) => { + const escapedRequests: string[] = []; + await context.route("**/*", async (route) => { + const url = new URL(route.request().url()); + if ((["http:", "https:"].includes(url.protocol) && url.hostname !== "127.0.0.1") || url.pathname.includes(`/tool-connections/review-${provider}/`)) { + escapedRequests.push(url.origin + url.pathname); await route.abort(); + } else await route.continue(); + }); + await go(page, provider, "complete-setup-journey"); + await expect(page.getByText("Step 1 of 2", { exact: true })).toBeVisible(); + await expect(page.getByRole("radio", { name: "Any human in the organization", exact: true })).toBeChecked(); + await expect(page.getByRole("radio", { name: "Any agent", exact: true })).toBeChecked(); + await page.getByRole("radio", { name: "Just me", exact: true }).click(); + await page.getByRole("radio", { name: "Just agents I pick", exact: true }).click(); + await expect(page.getByRole("button", { name: "Continue", exact: true })).toBeDisabled(); + await page.getByRole("button", { name: "Select agents", exact: true }).click(); + await page.getByRole("checkbox", { name: "Allow Researcher", exact: true }).check(); + await page.getByRole("checkbox", { name: "Allow Operator", exact: true }).check(); + await page.getByRole("button", { name: "Done", exact: true }).click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + await expect(page.getByText("Step 2 of 2", { exact: true })).toBeVisible(); + await expect(page.getByLabel("Connection name", { exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: /Test/ })).toHaveCount(0); + await page.getByRole("button", { name: "Back", exact: true }).click(); + await expect(page.getByRole("radio", { name: "Just me", exact: true })).toBeChecked(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + await page.getByRole("button", { name: "Use example configuration" }).click(); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + if (provider !== "zapier") { + await expect(page.getByText(/Finish signing in to/)).toBeVisible(); + await page.getByRole("button", { name: "Complete sign-in (simulation)" }).click(); + } + await expect(page.getByRole("heading", { name: "Actions", exact: true })).toBeVisible(); + await expect(page.getByText("Simulated action calls: 0")).toBeVisible(); + await expect(page.getByText(/Step \d of/)).toHaveCount(0); + await expect(page.getByRole("button", { name: /Finish setup|Skip test|Run test/ })).toHaveCount(0); + for (const radio of await page.getByRole("radio", { name: /: Allowed$/ }).all()) await expect(radio).toBeChecked(); + const rows = page.locator("[data-action-id]"); + const firstId = await rows.first().getAttribute("data-action-id"); + const secondId = await rows.nth(1).getAttribute("data-action-id"); + const first = page.locator(`[data-action-id="${firstId}"]`); + const second = page.locator(`[data-action-id="${secondId}"]`); + await first.getByRole("button", { name: "Test", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("heading", { name: /^Test / })).toBeVisible(); + await dialog.getByRole("button", { name: "Choose which agent to test as", exact: true }).click(); + await page.getByRole("button", { name: "Unassigned agent engineer", exact: true }).click(); + await expect(dialog.getByRole("button", { name: "Run", exact: true })).toHaveCount(0); + await dialog.getByRole("button", { name: "Choose which agent to test as", exact: true }).click(); + await page.getByRole("button", { name: "Researcher engineer", exact: true }).click(); + await dialog.getByRole("button", { name: "Run", exact: true }).click(); + await expect(dialog.getByText(/^Worked\./)).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByText("Simulated action calls: 1")).toBeVisible(); + await first.getByRole("radio", { name: /: Ask first$/ }).click(); + await second.getByRole("radio", { name: /: Off$/ }).click(); + await page.getByRole("button", { name: "Refresh actions", exact: true }).click(); + await expect(page.getByRole("radio", { name: "Newly discovered tool: Allowed", exact: true })).toBeChecked(); + await expect(first.getByRole("radio", { name: /: Ask first$/ })).toBeChecked(); + await expect(second.getByRole("radio", { name: /: Off$/ })).toBeChecked(); + await page.getByRole("button", { name: "Connection settings", exact: true }).click(); + await page.getByRole("button", { name: "Who can use this connection", exact: true }).click(); + await expect(page.getByRole("radio", { name: "Just me", exact: true })).toBeChecked(); + await expect(page.getByRole("radio", { name: "Just agents I pick", exact: true })).toBeChecked(); + await page.getByRole("button", { name: "Done", exact: true }).click(); + await page.getByRole("button", { name: "Reconnect", exact: true }).click(); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + if (provider !== "zapier") await page.getByRole("button", { name: "Complete sign-in (simulation)" }).click(); + await expect(first.getByRole("radio", { name: /: Ask first$/ })).toBeChecked(); + await expect(second.getByRole("radio", { name: /: Off$/ })).toBeChecked(); + await second.getByRole("button", { name: "Test", exact: true }).click(); + await expect(dialog.getByRole("button", { name: "Run", exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: "Connection settings", exact: true }).click(); + await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await page.getByRole("button", { name: "Disconnect connection", exact: true }).click(); + await expect(page.getByText("Disconnected", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Permissions", exact: true })).toBeDisabled(); + expect(escapedRequests).toEqual([]); + }); + + test(`${provider}: state matrix renders at desktop and narrow widths`, async ({ page, request }, testInfo) => { + const index = await (await request.get("/index.json")).json(); + const ids = Object.keys(index.entries).filter((id) => id.startsWith(`apps-connections-${provider}--`)); + expect(ids.length).toBeGreaterThanOrEqual(18); + expect(ids.some((id) => /empty-catalog|--test-|--paperclip-approval|--provider-approval/.test(id))).toBe(false); + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + for (const width of [1280, 390]) { + await page.setViewportSize({ width, height: 900 }); + for (const id of ids) { + await test.step(`${width}: ${id}`, async () => { + await page.goto(`/iframe.html?id=${id}&viewMode=story&globals=theme:${width === 1280 ? "dark" : "light"}`); + await expect(page.locator(`[data-remote-mcp-provider="${provider}"]`)).toBeVisible(); + await expect(page.locator(".sb-errordisplay")).toBeHidden(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + if (/--(initial-setup|connection-details|manage-tool-permissions)$/.test(id)) { + await page.evaluate(() => document.fonts.ready); + await page.screenshot({ path: testInfo.outputPath(`${id}-${width}.png`), fullPage: true, animations: "disabled" }); + } + }); + } + } + expect(pageErrors).toEqual([]); + }); + + test(`${provider}: save and resume preserves credentials in memory and skips OAuth for tokens`, async ({ page }) => { + await go(page, provider, "advanced-authentication"); + await page.getByRole("button", { name: "Use example configuration" }).click(); + const originalUrl = await page.getByLabel("MCP server URL", { exact: true }).inputValue(); + await page.getByRole("button", { name: "Save & exit", exact: true }).click(); + await page.getByRole("button", { name: "Resume setup", exact: true }).click(); + await expect(page.getByLabel("MCP server URL", { exact: true })).toHaveValue(originalUrl); + const storage = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } })); + expect(storage).not.toContain("review-only-not-a-secret"); + expect(storage).not.toContain(originalUrl); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Actions", exact: true })).toBeVisible(); + await expect(page.getByText(/Finish signing in to/)).toHaveCount(0); + await expect(page.getByText("Simulated action calls: 0")).toBeVisible(); + }); +} + +test("Cancel, invalid URL and retry keep the form usable by keyboard", async ({ page }) => { + await go(page, "arcade", "connection-details"); + const url = page.getByLabel("MCP server URL", { exact: true }); + await url.fill("not-a-url"); + await url.press("Enter"); + await expect(page.getByText("Enter a valid MCP URL")).toBeVisible(); + await url.fill("https://arcade.example.invalid/review/mcp"); + await page.getByRole("button", { name: "Try again", exact: true }).click(); + await expect(page.getByText("Finish signing in to Arcade")).toBeVisible(); + await page.getByRole("button", { name: "Cancel sign-in", exact: true }).click(); + await expect(url).toHaveValue("https://arcade.example.invalid/review/mcp"); + await page.getByRole("button", { name: "Try again", exact: true }).press("Enter"); + await page.getByRole("button", { name: "Complete sign-in (simulation)" }).click(); + await expect(page.getByRole("heading", { name: "Arcade", exact: true })).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(page.getByRole("button", { name: "Connection settings", exact: true })).toBeFocused(); +}); + +test("Zapier has no browser sign-in state or OAuth option", async ({ page, request }) => { + const index = await (await request.get("/index.json")).json(); + const ids = Object.keys(index.entries).filter((id) => id.startsWith("apps-connections-zapier--")); + expect(ids.some((id) => /sign-in/.test(id))).toBe(false); + await go(page, "zapier", "advanced-authentication"); + await expect(page.getByRole("option", { name: "Automatic (sign in if required)" })).toHaveCount(0); +}); + +for (const [action, headline] of [["Approve and resume", /^Worked\./], ["Decline", "Request declined"], ["Cancel request", "Request cancelled"]] as const) { + test(`Executor provider handoff: ${action} continues the same execution`, async ({ page }) => { + await go(page, "executor", "manage-tool-permissions"); + await page.getByLabel("Test response", { exact: true }).selectOption("provider"); + await page.getByRole("button", { name: "Test", exact: true }).first().click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: "Run", exact: true }).click(); + await expect(dialog.getByText("review-execution-001", { exact: true })).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Run again", exact: true })).toBeDisabled(); + await dialog.getByRole("button", { name: action, exact: true }).click(); + await expect(dialog.getByText(headline)).toBeVisible(); + await expect(dialog.getByText("Approval needed in Executor", { exact: true })).toHaveCount(0); + await expect(page.getByText("Simulated action calls: 2")).toBeVisible(); + }); +} + +test("Regular permissions filter actions and open the shared test error/approval states", async ({ page }, testInfo) => { + await go(page, "zapier", "manage-tool-permissions"); + await page.getByRole("button", { name: "Write 1", exact: true }).click(); + await expect(page.locator("[data-action-id]")).toHaveCount(1); + await page.getByRole("button", { name: "All 3", exact: true }).click(); + await page.getByRole("textbox", { name: "Find an action", exact: true }).fill("spreadsheet rows"); + await expect(page.locator("[data-action-id]")).toHaveCount(1); + await page.getByLabel("Test response", { exact: true }).selectOption("error"); + await page.getByRole("button", { name: "Test", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: "Run", exact: true }).click(); + await expect(dialog.getByText("Review resource was not found. Check the arguments.", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + await page.getByRole("radio", { name: /: Ask first$/ }).click(); + await page.getByRole("button", { name: "Test", exact: true }).click(); + await dialog.getByRole("button", { name: "Run", exact: true }).click(); + await expect(dialog.getByText("Sent for your OK.", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByText("Simulated action calls: 1")).toBeVisible(); + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("radio", { name: /: Allowed$/ }).click(); + await page.getByRole("button", { name: "Test", exact: true }).click(); + await page.evaluate(() => document.fonts.ready); + await page.screenshot({ path: testInfo.outputPath("canonical-test-dialog-narrow.png"), animations: "disabled" }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); +}); diff --git a/ui/public/brands/apps/arcade.png b/ui/public/brands/apps/arcade.png new file mode 100644 index 0000000000000000000000000000000000000000..73721aa02571745f2a74aaf4c22319b7c4573816 GIT binary patch literal 63711 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

KWY~u$O+G5l_PQn<$ga(Lw?kHLTGSD(RnN? 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 literal 0 HcmV?d00001 diff --git a/ui/public/brands/apps/executor.png b/ui/public/brands/apps/executor.png new file mode 100644 index 0000000000000000000000000000000000000000..ae9c3fd9736e5f098796a9b8ec8d5fda8e14fa97 GIT binary patch literal 7256 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 m7cXACc=6)Jix;nG*Z%`aH$j!=fYXcs0000 ({ SecretBindingPicker: () => null })); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +const schema: JsonSchemaNode = { type: "object", properties: { tools: { type: "array", items: { type: "object", properties: { arguments: { type: "object", additionalProperties: true } } } } } }; +let dispose: (() => void) | undefined; +afterEach(async () => { await act(async () => dispose?.()); document.body.innerHTML = ""; }); +describe("remote MCP open-ended argument inputs", () => { + it("edits nested tool arguments and rejects malformed JSON instead of submitting the previous object", async () => { + const container = document.createElement("div"); document.body.append(container); + const root = createRoot(container); dispose = () => root.unmount(); + let actual: Record = {}; + function Form() { + const [values, setValues] = useState({ tools: [{ arguments: {} }] } as Record); + actual = values; + return ; + } + await act(async () => root.render(

)); + const input = container.querySelector('textarea[aria-label="Arguments JSON"]')!; + expect(input).not.toBeNull(); + const fill = async (value: string) => act(async () => { + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await fill('{"repoName":"paperclipai/paperclip"}'); + expect(actual).toEqual({ tools: [{ arguments: { repoName: "paperclipai/paperclip" } }] }); + expect(validateJsonSchemaForm(schema, actual)).toEqual({}); + await fill('{"repoName":'); + expect(validateJsonSchemaForm(schema, actual)).toEqual({ "/tools/0/arguments": "Enter a valid JSON object" }); + await fill("[]"); + expect(validateJsonSchemaForm(schema, actual)["/tools/0/arguments"]).toBeTruthy(); + await fill("{}"); + expect(validateJsonSchemaForm(schema, actual)).toEqual({}); + }); + it("does not permit JSON null or array values for a required object", () => { + const args: JsonSchemaNode = { type: "object", required: ["arguments"], properties: { arguments: { type: "object" } } }; + for (const value of [null, [], "broken"]) expect(Object.keys(validateJsonSchemaForm(args, { arguments: value }))).toHaveLength(1); + }); +}); diff --git a/ui/src/components/JsonSchemaForm.tsx b/ui/src/components/JsonSchemaForm.tsx index d1466eb8fc..d07242b4d6 100644 --- a/ui/src/components/JsonSchemaForm.tsx +++ b/ui/src/components/JsonSchemaForm.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ChevronDown, ChevronRight, @@ -197,6 +197,9 @@ export function validateField( if (type === "secret-ref" && typeof value === "object") { return "Invalid secret reference"; } + if (type === "object" && (typeof value !== "object" || Array.isArray(value))) { + return "Enter a valid JSON object"; + } if (type === "string" || type === "secret-ref") { const str = String(value); @@ -524,7 +527,7 @@ const EnumField = React.memo(({ onValueChange={handleChange} disabled={disabled} > - + @@ -620,6 +623,8 @@ const SecretField = React.memo(({
{isVisible ? (