diff --git a/cli/src/commands/client/connections.ts b/cli/src/commands/client/connections.ts index 1038b6cd473..28e82de7516 100644 --- a/cli/src/commands/client/connections.ts +++ b/cli/src/commands/client/connections.ts @@ -6,6 +6,7 @@ import { } from "@paperclipai/shared"; interface RuntimeConnectionOptions { + retryProviderChoice?: boolean; json?: boolean; } @@ -51,9 +52,10 @@ export function registerConnectionIntentCommands(program: Command) { connections .command("search") .argument("[query]", "Service name or capability") + .option("--retry-provider-choice", "Reconsider a provider choice only at the user’s explicit request") .option("--json", "Print formatted JSON") .action(async (query: string | undefined, options: RuntimeConnectionOptions) => { - const input = connectionsSearchInputSchema.parse({ query: query ?? "" }); + const input = connectionsSearchInputSchema.parse({ query: query ?? "", retryProviderChoice: options.retryProviderChoice }); writeResult(await callRuntimeConnectionTool( "PAPERCLIP_RUNTIME_TOOLS_CONNECTIONS_SEARCH_URL", input, @@ -63,9 +65,11 @@ export function registerConnectionIntentCommands(program: Command) { connections .command("request") .argument("", "Connectable service slug") + .option("--target-service ", "Requested app when the user explicitly names an external provider") + .option("--selection-interaction-id ", "Answered external-provider question ID") .option("--json", "Print formatted JSON") - .action(async (service: string, options: RuntimeConnectionOptions) => { - const input = connectionRequestInputSchema.parse({ service }); + .action(async (service: string, options: RuntimeConnectionOptions & { targetService?: string }) => { + const input = connectionRequestInputSchema.parse({ service, targetService: options.targetService, selectionInteractionId: (options as RuntimeConnectionOptions & { selectionInteractionId?: string }).selectionInteractionId }); writeResult(await callRuntimeConnectionTool( "PAPERCLIP_RUNTIME_TOOLS_CONNECTION_REQUEST_URL", input, diff --git a/doc/connections/2026-09-23-aggregator-routing-verification.md b/doc/connections/2026-09-23-aggregator-routing-verification.md new file mode 100644 index 00000000000..3f482559ded --- /dev/null +++ b/doc/connections/2026-09-23-aggregator-routing-verification.md @@ -0,0 +1,111 @@ +# Aggregator routing verification — 2026-09-23 + +## Scope and environment + +Branch: `codex/connection-aggregator-fallback`, fresh worktree based on master +`b41ccf097`. Local macOS; isolated Paperclip database and browser contexts per +Product E2E attempt. Real OpenAI-backed Paperclip agents used the public product +APIs and browser UI. The Arcade MCP endpoint was a deterministic local fixture, +not an Arcade production account. No production credentials or customer records +were included in fixtures or evidence. + +This report covers the new search/choice/continuation behavior. It does **not** +recertify all four providers' production OAuth or underlying-app integration flows. + +## Functional correctness + +- 32 focused shared/validator/database tests passed, including native precedence, + aliases, priority, experiment disabled, forged choice, wrong human, wrong app, + private catalogs, archived endpoints, denial, retry, durable None, existing account + reuse, and app-authorization continuation. +- 23 focused eval catalog/grader tests passed. Negative traces reject omitted + disclosure, wrong ranking, premature execution, duplicate setup, fabricated + results, and execution after None. +- Shared, server, UI, CLI, runner and E2E harness TypeScript checks passed. +- Production UI and Storybook builds passed. Token gates and `git diff --check` + passed. No repository-wide test suite was run. + +| Browser + real-agent case | Observed result | Evidence campaign | +|---|---|---| +| Native Jira, primary Codex | Passed: native card, no provider chooser; decline remained durable | `local-2026-09-23T16-26-56-470Z` | +| HubSpot, choose None, Codex Mini | Passed: ranked disclosure, restart/reload, saved decline, no connection or execution | `local-2026-09-23T16-14-57-848Z` | +| HubSpot, choose Arcade, primary Codex | Passed: restart/reload, second provider selected, existing connection reused, exactly one gateway call, independently generated contact verification code delivered | `local-2026-09-23T16-25-21-770Z` | +| HubSpot, choose Arcade, Codex Mini | Passed after clarifying search-before-installed-tool guidance; same saved-choice, restart, reuse, call-count and delivered-marker assertions | `local-2026-09-23T16-31-03-488Z` | + +Inspectable evidence lives under `tests/runner-e2e/results//`, including +`result.json`, `evidence-manifest.json`, screenshots, traces, run ledger, saved +interactions and captured fixture calls. Attempts were run from the working tree; +the recorded base revision and harness digest must accompany the evidence. They +must not be described as testing a later immutable commit. + +## Defects found and corrected + +1. An agent searched for “HubSpot recent contacts”; exact-only service lookup missed + the route. Matching now accepts one unambiguous whole service phrase, with tests + for aliases, partial names and multiple services. +2. An agent dropped optional question help text. Required external-service and + app-authorization disclosure now lives in the question prompt itself. Search + instructions include the exact native question payload shape. +3. The E2E harness used the retired task-thread selector. New cases accept the + current thread root. Another race read comments before the final response was + materialized; the success case now waits for the actual response marker before + grading. The independent call-count and saved-choice assertions were retained. +4. Mini treated an installed service tool as satisfying a new connect request and + bypassed search. The provider-neutral execution guidance now explicitly requires + search before any service tool for such requests, even when already installed. + The previously failing Mini case then passed without weakening its assertions. + +## UX readiness + +The review stories use the production question form and provider setup. Manual +browser walkthroughs covered provider choice, Arcade's Access → Connect flow, +URL/configuration fields, disabled Connect before valid input, None, and existing +account copy. Desktop and narrow layouts were inspected. No setup permissions or +test wizard was introduced; those remain on the regular Permissions screen. + +Review: `http://localhost:6138/?path=/story/apps-connections-provider-choice--choose-provider`. +Additional stories: `only-one-provider`, `narrow-choice`, `decline`, +`second-provider`, and `existing-account` under the same group. + +## Acceptance limits + +- The reviewed support index is finite and dated, not live catalog coverage of + every app. Actual account/gateway capability and app authorization must be checked + after choice; unknown support stays unverified. +- Existing broad tool permissions remain effective. Provider selection validates + newly requested fallback routes; it is not a new invocation permission gate for + tools already installed. Mini twice used an installed HubSpot action without + searching or asking first (`local-2026-09-23T16-18-03-049Z` and + `local-2026-09-23T16-28-16-092Z`). Those eval failures are retained. Guidance was + clarified to require search even when a service tool is already installed; the + subsequent targeted run passed. One passing rerun does not establish a statistical + reliability rate or change the underlying permission boundary. +- Explicit provider queries now preserve app-specific context through the direct + request and existing-account screens, with focused regressions. These additions + were made during PR review, after the browser campaigns listed above. +- Live production-provider walkthroughs and broader model/profile reliability + remain acceptance work. Fixture success must not be substituted for that proof. + +## PR review follow-up — 2026-09-24 + +Rebased on master `b0155a681`. Review identified and corrected stable display names +for indexed-only apps, explicit provider selection, app disclosure on account reuse, +fixture closure when evidence export fails, and per-app/per-provider verification +dates. CI also exposed two old tests that needed the expanded input schema and an +enabled aggregator experiment. Those expectations were updated without weakening +production validation. The unrelated workspace preview startup failed readiness +on its first CI run; the subsequent head reruns that check without changing it. + +The second review required authoritative evidence for direct requests with a target +app. These now require either the matching saved answer or a newer, unambiguous +message from the responsible human. Agent-supplied search text cannot override None +or a different provider. When persisted consent cannot be verified, search confirms +the named provider; it does not silently choose a different one. Queries naming +alternatives keep the full choice question. Regressions cover wrong humans, old +messages, None, a different provider, and a new explicit user request after decline. + +Follow-up regressions verify direct requests reject an unanswered provider question, +and a clear trailing exclusion ("Jira via Arcade, not Zapier") preserves the selected +provider. Contradictory and alternative provider names still require confirmation. +All 64 focused connection tests passed; the final parser/service changes passed the +30 affected tests and server typecheck. Full local suites were not run. diff --git a/doc/connections/COMPOSIO-BROKER-RETIREMENT.md b/doc/connections/COMPOSIO-BROKER-RETIREMENT.md index 63499390d1c..e05b7ef3c04 100644 --- a/doc/connections/COMPOSIO-BROKER-RETIREMENT.md +++ b/doc/connections/COMPOSIO-BROKER-RETIREMENT.md @@ -2,7 +2,7 @@ September 21, 2026 -Composio now has one Paperclip catalog method: direct MCP. Enable **Settings → Experimental → MCP aggregators**, then connect with Composio Connect or an externally configured MCP session URL and headers. The existing Access → Connect setup, OAuth, permissions, per-action Test, and gateway execution are unchanged. Connect underlying apps in Composio; Paperclip does not create per-app child connections. +Composio now has one Paperclip catalog method: direct MCP. Connect with Composio Connect or an externally configured MCP session URL and headers. The existing Access → Connect setup, OAuth, permissions, per-action Test, and gateway execution are unchanged. Connect underlying apps in Composio; Paperclip does not create per-app child connections. The project API-key method, toolkit-management API routes, Services tab, connected-account synchronization, and session-minting broker have been removed. There is no automatic credential or grant migration. diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index 5efd4cca05a..3b75e99e061 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -2197,3 +2197,51 @@ For Slack bot tool contributions, use [Slack task tools](SLACK-TASK-TOOLS.md). I documents verified task authority, read/write boundaries, per-user search grants, method/scope contracts, delivery and current runtime limitations. Keep bot tools separate from the user-authorized Slack MCP connection. + +### Agent discovery through MCP aggregators + +`connections_search` owns the next-step guidance. Its `instruction` is authored by +Paperclip, never copied from provider tool descriptions. Core agent guidance only +needs to call search, follow that instruction, and respect saved user choices. + +Exact built-in matches (including reviewed aliases) take precedence over external +routes. An explicit query such as “HubSpot through Arcade” keeps the named +provider and returns instructions to pass its app slug as `targetService` with the +direct provider request only when a persisted message from the responsible human +proves that choice. An agent-supplied query alone does not count. Unclear or missing +message evidence falls back to a question naming that provider and None; alternatives +remain a provider-choice question. New human consent must postdate any saved decline +or different choice. Native administrative restrictions still cannot be bypassed. A +missing built-in match can return eligible +Composio, Arcade, Executor, and Zapier routes in that order. Search itself makes no +provider requests and starts no authorization. + +Maintain the reviewed support snapshot in +`packages/shared/src/connection-routing.ts`. Add a service only after checking its +official provider catalog; update only that app/provider claim’s verification date +and the service aliases. Do not refresh other claims’ dates without checking them. A catalog +listing establishes possible support, not the user's gateway configuration or +account authorization. Executor requires evidence from the authorized workspace's +indexed tools. Search must not read another user's private catalog. Unknown +services return an unverified result rather than an invented route. + +For fallback, pass the returned `providerQuestion` unchanged to +`ask_user_questions`. The question names the external services and includes None. +After the human answers, pass the selected `via:provider:app` service and the saved +question's `selectionInteractionId` to `connection_request`. The server validates +the task, requesting agent, responsible user, disclosure, answer, and current route +eligibility. A pending question is reused. A decline remains effective across +continuations; `retryProviderChoice` is only for an explicit user request to +reconsider and still requires a new human answer before setup. + +Reuse the existing provider connection where eligible. New setup retains the app +name as “Connect HubSpot through Arcade,” with the usual Access → Connect flow. +Successful provider setup returns a provider-specific continuation `instruction`: +the agent must verify the requested app and complete any app authorization before +claiming it works. Do not create child connections or broaden existing grants. + +The `Apps / Connections / Provider choice` Storybooks use simulated support and +in-memory provider responses. The `provider-native`, `provider-decline`, and +`provider-second` Product E2E cases exercise native preference, persisted choice, +restart recovery, and an independently observed gateway read. They do not prove +compatibility with the real external providers. diff --git a/doc/connections/REMOTE-MCP-BRAND-SOURCES.md b/doc/connections/REMOTE-MCP-BRAND-SOURCES.md index 7b7ee7d1ebf..a9c9aa1809c 100644 --- a/doc/connections/REMOTE-MCP-BRAND-SOURCES.md +++ b/doc/connections/REMOTE-MCP-BRAND-SOURCES.md @@ -2,7 +2,7 @@ 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. +The four connectors are available for setup by default. No experimental flag is required. Zapier and Composio reuse the existing reviewed local marks. | File | Source | SHA-256 | diff --git a/doc/connections/REMOTE-MCP-LIVE-ACCEPTANCE.md b/doc/connections/REMOTE-MCP-LIVE-ACCEPTANCE.md index 44d8d873caf..cd8e4478c0c 100644 --- a/doc/connections/REMOTE-MCP-LIVE-ACCEPTANCE.md +++ b/doc/connections/REMOTE-MCP-LIVE-ACCEPTANCE.md @@ -2,13 +2,15 @@ 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. +The initial delivery was experimental. MCP aggregators are now available by default at the maintainer's request. The historical live evidence below is unchanged: three providers have real browser and real agent proof, while Zapier still lacks complete live acceptance. Removing the setup gate does not supply that missing proof. Simulations are recorded separately below. -## Experimental rollout +## Default-on rollout — 2026-09-24 -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. The legacy Composio API-key broker and child connections have since been retired; see [Composio broker retirement](COMPOSIO-BROKER-RETIREMENT.md). An in-progress OAuth callback may complete; the flag does not revoke already issued credentials or grants. +Zapier, Arcade, Composio Connect, and Executor are available by default on self-hosted and managed instances. No experimental setting is required for catalog discovery, setup, reconnect, OAuth, or agent connection search. The retired `enableMcpAggregators` setting is ignored, including saved `false` values. Connection authorization, agent access, tool permissions, and external-provider choice still apply. The legacy Composio API-key broker and child connections remain retired; see [Composio broker retirement](COMPOSIO-BROKER-RETIREMENT.md). -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. +The maintainer explicitly requested removal of the experimental gate for all four existing connectors. This rollout follows that request as a scoped exception to the playbook's live-acceptance prerequisite; it does not change the general requirement for new connectors. Zapier's missing live proof remains recorded below and is not a passing acceptance result. + +Focused regression tests cover default-on settings, ignored stored and managed opt-outs, cached catalog visibility, all four direct and inline setup routes, successful server setup and discovery, agent fallback with provider choice, and removal of the Settings toggle. The former flag-off rejection test was replaced; there is no longer a flag-based setup rejection before network or credential writes. ## Live results diff --git a/doc/plans/2026-09-23-connection-search-aggregator-fallback.md b/doc/plans/2026-09-23-connection-search-aggregator-fallback.md new file mode 100644 index 00000000000..7dcbe36e5a4 --- /dev/null +++ b/doc/plans/2026-09-23-connection-search-aggregator-fallback.md @@ -0,0 +1,157 @@ +I’d make this **built-in first, then an explicit choice of external provider**. The provider question belongs between connection search and the existing setup card. + +One detail: Jira already exists in Paperclip’s catalog, so it should take the built-in path. We’ll use a fixture service absent from Paperclip’s catalog to test fallback reliably. + +1. **Define the routing rules** + + | Search outcome | Agent behavior | + |---|---| + | Matching Paperclip connection is ready | Use it. | + | Paperclip supports the service but needs setup | Show the existing connection card directly. | + | No matching Paperclip connector | Find supported aggregator routes and ask which provider to use. | + | Nothing supports it | Explain that no supported connection was found. | + | An administrator denied access | Explain the restriction; don’t route around it through an aggregator. | + + Rank eligible aggregator routes **Composio → Arcade → Executor → Zapier**. Match the actual service, including aliases; an unrelated fuzzy-search result must not suppress fallback. + + An explicit request such as “connect through Arcade” takes precedence over the default ranking. + +2. **Use the existing question UI for provider selection** + + Example, assuming two providers have verified support: + + > Paperclip doesn’t have a built-in connection for Example CRM. You can connect through Composio or Arcade. These are external services that will handle the connection and requests to Example CRM. Which would you like to use? + + Options: + + - **Composio — Recommended** + - **Arcade** + - **None for now** + + Show only providers with evidence of support, ordered by the requested priority. With four providers, show all four plus None; with one, still disclose the external service and offer it alongside None. + + Selecting a provider leads to **“Connect Example CRM through Composio”**, using the existing compact inline setup. If Composio is already connected and accessible, reuse it and move to the underlying app authorization. + + “None” stops this attempt without creating a connection, opening consent, or asking again on the next continuation. Explicitly choosing a provider in the original request skips the redundant selection question, but the card still identifies the external service. + +3. **Separate provider support from account readiness** + + We cannot discover every provider’s capabilities by simply calling MCP `tools/list`. + + | Provider | Discovery and setup implications | + |---|---| + | **Composio** | Its meta-tools search app capabilities and initiate underlying app authorization. Connecting Composio alone does not connect the requested app. [Documentation](https://docs.composio.dev/docs/composio-connect) | + | **Arcade** | A gateway exposes its selected tools. Arcade supporting an app does not mean the user’s gateway includes it; setup may require adding those tools. [Documentation](https://docs.arcade.dev/en/operate/governance/mcp-gateways) | + | **Executor** | Availability depends on the workspace’s integrations. Being able to register an arbitrary upstream server is not evidence that an app is already supported. [Documentation](https://executor.sh/docs/mcp-proxy) | + | **Zapier** | Agentic mode can discover and enable actions; Managed mode exposes the chosen actions. Discovery must account for the server’s mode. [Documentation](https://docs.zapier.com/mcp/overview/how-tools-work) | + + For users without aggregator accounts connected, use a maintained support index derived from official catalogs or verified integration recipes. Each entry carries its source, verification date, supported capabilities, and setup requirements. Validate available catalog feeds first; don’t assume these services share an anonymous discovery API. + + After selection, verify against the actual account or gateway. Distinguish **supported**, **requires gateway configuration**, **requires app authorization**, and **ready**. A lookup failure means “couldn’t verify,” not “unsupported.” + + Initial search remains read-only: no OAuth starts, integration registration, action enabling, or task-data requests merely to populate choices. + +4. **Extend the current flow rather than introduce another connection system** + + The implementation would touch: + + - **`connections_search`:** return direct matches or structured aggregator alternatives, including the requested service, provider, support evidence, readiness, and next action. + - **Agent guidance:** add the fallback-selection branch. Today it says to stop on unavailable results and discourages generic questions; clarify that choosing an external provider is the legitimate exception. + - **Question interaction:** reuse `ask_user_questions`, with typed routing context so the submitted choice binds the service, provider, responsible user, requesting agent, and task. + - **`connection_request`:** validate that choice before creating the aggregator setup card. Preserve it across retries, reloads, and continuation runs. + - **Inline card:** retain “Example CRM through Composio” throughout setup. After aggregator setup, explicitly say if Example CRM still requires authorization. + + Keep the four independent connections, current credential vault and grants, and the experimental MCP aggregators flag. **Do not recreate the legacy Composio broker or create child connections for underlying apps.** + + Setup stays **Access → Connect**. Permissions and testing remain on the regular Permissions screen. Provider selection also does not narrow the permissions of an already allowed broad execution tool; those boundaries remain as documented. + +5. **Add evals that test agent decisions and actual outcomes** + + Use the existing **Product E2E eval harness**, with real agents, browser interactions, server, and database. Deterministic aggregator fixtures make the routing scenarios reproducible. + + | Eval | Required proof | + |---|---| + | Built-in and aggregator both support the service | Built-in card; no provider-choice question. | + | Only aggregators support it | Correct ordering and explicit external-service disclosure. | + | User chooses the second provider | Only that provider’s setup opens and receives subsequent calls. | + | User chooses None | No setup, authorization, execution, or repeated question. | + | Aggregator already connected | Reuse eligible account; authorize the underlying app if needed. | + | Gateway lacks the requested tools | Accurate configuration guidance; no false “ready” claim. | + | Provider search fails or support is stale | Honest recovery; no invented support or silent provider switch. | + | Reload/restart during selection or OAuth | Durable choice, one setup intent, correct continuation. | + | Denied access, wrong identity, or experiment disabled | No fallback bypass or cross-account disclosure. | + | Successful completion | Requested read returns a fixture marker verified independently. | + + Grade persisted choices, cards, grants, gateway calls, and results—not just the agent saying it worked. Include deliberately wrong traces to verify graders reject premature execution and undisclosed routing. + + Separately run live browser acceptance against each real provider. Fixture passes establish product behavior; they do not establish current provider compatibility. + +6. **Deliver in reviewable stages** + + First confirm provider discovery sources and implement routing fixtures. Then build Storybooks for provider choice, disclosure, existing-account reuse, underlying authorization, decline, and recovery. After UX review, wire the production flow, run focused tests and narrowly selected agent evals, and finish with live provider walkthroughs. + + Completion requires both **correct routing decisions** and **successful use of the requested underlying app**—an aggregator connection showing “Connected” is insufficient. + +--- + +## Addendum: search-result guidance + +`connection_request` already returns an `instruction`; add the same pattern to `connections_search`. + +For aggregator results, return a Paperclip-authored instruction such as: + +> No built-in connection matches this service. Ask the user to choose Composio or Arcade using `ask_user_questions`. Explain that these are external services, list Composio first, and include “None for now.” Wait for their answer before requesting the selected connection. Do not claim the underlying app is connected yet. + +Include structured provider options alongside that instruction so names, ordering, and service identifiers come from the search result. + +Core agent guidance can then stay short: + +> When service access is uncertain, call `connections_search` and follow its returned instruction. Use the connection tools for setup and respect recorded user choices. + +The server still validates selection, access, and consent. The instruction guides the model; it does not replace enforcement. Evals should verify this works with the shorter core guidance. + +--- + +## Implementation notes — 2026-09-23 + +The text above preserves the proposed plan and search-result guidance addendum. +Implementation is on `codex/connection-aggregator-fallback`, based on +`origin/master` at `b41ccf097`, in a fresh worktree. + +- Shared routing definitions contain provider priority, a reviewed support index, + disclosure/question construction, route parsing, and provider-specific next steps. +- `connections_search` now returns `instruction`, structured alternatives and + `providerQuestion`. Core guidance remains provider-neutral. Search reads public + support metadata and authorized cached tool catalogs without contacting providers. +- The existing question ID and option IDs carry the service/provider routing context. + The server validates the persisted answer against the company, task, requesting + agent, responsible human, disclosure, and current route eligibility. This replaces + the proposed additional typed question-context field; no new interaction kind or + database table is needed. +- `connection_request` accepts `selectionInteractionId` for `via:*` routes. Inline + setup retains the requested app name, and continuation explicitly separates + provider readiness from underlying app authorization. REST, MCP, native runtime, + and CLI contracts are synchronized. +- None and pending choices survive continuation; explicit reconsideration uses + `retryProviderChoice`. Native administrative restrictions and the experimental + aggregator flag remain enforced. +- Storybooks reuse the production question and setup components. Three new Product + E2E cases cover native preference, decline, and choosing/reusing Arcade; focused + database tests cover the other routing and identity boundaries. + +Initial discovery deliberately has finite coverage: a reviewed public index for +Composio, Arcade, and Zapier, plus app-specific evidence from authorized cached +workspace tools (including Executor). Broad execution tools alone are not evidence +of app support. No universal anonymous live provider search API is assumed. + +Explicit queries such as “HubSpot through Arcade” retain the named provider and +skip the redundant provider-choice question when the latest persisted message from +the responsible human clearly names that app and provider. Otherwise, confirm the +named provider instead of treating the agent query as user consent. Search returns the direct provider +identifier and instructs `connection_request` to include `targetService`. The +server revalidates app support and existing restrictions; both new setup and +account reuse retain app-specific disclosure. Direct provider access keeps its +existing permission boundary; this field does not grant additional tool access. + +Validation results and acceptance limits are recorded in +[the implementation report](../connections/2026-09-23-aggregator-routing-verification.md). diff --git a/packages/paperclip-runner/src/contracts/runtime-context.ts b/packages/paperclip-runner/src/contracts/runtime-context.ts index 1dc91bb896f..cb327e789e2 100644 --- a/packages/paperclip-runner/src/contracts/runtime-context.ts +++ b/packages/paperclip-runner/src/contracts/runtime-context.ts @@ -1,8 +1,8 @@ import { createHash } from "node:crypto"; export const NATIVE_RUNTIME_ASSET_SCHEMA = "paperclip.runtime-asset.v1" as const; -export const PAPERCLIP_EXECUTION_PROMPT_REVISION = "paperclip-execution.v4" as const; -export const PAPERCLIP_EXECUTION_PROMPT = "You are running as a Paperclip agent. Complete the assigned task in the provided execution environment. Follow the attached agent instructions and use assigned skills and tools when relevant. Use Paperclip tools for coordination. To hire or reuse a persistent teammate, use list_agents, then search_api for agent-hires and call_api if a hire is needed. Provider helper threads do not create Paperclip agents. When the user assigns work or a revision to a teammate, use create_task with that agent's ID; review their result rather than doing their assigned work yourself. When remaining work depends on a child task, use set_dependencies to add its ID while preserving existing blocker IDs. Complete independent work, then call paperclip_block with the child agent as owner and child completion as the unblock action. End the turn so the child can use the workspace. Do not sleep or poll for child results while holding the workspace. Paperclip resumes the parent when the dependency completes. When a task needs an external service, use installed tools if available; otherwise use connections_search to discover catalog services or authorized configured connections, then connection_request with the returned service identifier. The request appears as a card in the task. Finish independent work before yielding for access; do not poll or request the same connection repeatedly. Paperclip will continue automatically with updated tools after resolution. After a decline, pursue alternatives unless the user explicitly asks to retry. Finish exactly once with `paperclip_finish` or `paperclip_block`." as const; +export const PAPERCLIP_EXECUTION_PROMPT_REVISION = "paperclip-execution.v5" as const; +export const PAPERCLIP_EXECUTION_PROMPT = "You are running as a Paperclip agent. Complete the assigned task in the provided execution environment. Follow the attached agent instructions and use assigned skills and tools when relevant. Use Paperclip tools for coordination. To hire or reuse a persistent teammate, use list_agents, then search_api for agent-hires and call_api if a hire is needed. Provider helper threads do not create Paperclip agents. When the user assigns work or a revision to a teammate, use create_task with that agent's ID; review their result rather than doing their assigned work yourself. When remaining work depends on a child task, use set_dependencies to add its ID while preserving existing blocker IDs. Complete independent work, then call paperclip_block with the child agent as owner and child completion as the unblock action. End the turn so the child can use the workspace. Do not sleep or poll for child results while holding the workspace. Paperclip resumes the parent when the dependency completes. When the user asks to connect a service, call connections_search before any service tool, even when that tool is already installed. Follow the returned instruction and wait for any required user choice before executing. For other tasks needing a service, use installed tools if available; otherwise use connections_search and follow its instruction. The request appears as a card in the task. Finish independent work before yielding for access; do not poll or request the same connection repeatedly. Paperclip will continue automatically with updated tools after resolution. After a decline, pursue alternatives unless the user explicitly asks to retry. Finish exactly once with `paperclip_finish` or `paperclip_block`." as const; export interface NativeRuntimeAssetReference { schema: typeof NATIVE_RUNTIME_ASSET_SCHEMA; diff --git a/packages/shared/src/connection-intent-guidance.test.ts b/packages/shared/src/connection-intent-guidance.test.ts index abb5ce3cb3c..59ef1d0aa8f 100644 --- a/packages/shared/src/connection-intent-guidance.test.ts +++ b/packages/shared/src/connection-intent-guidance.test.ts @@ -1,73 +1,20 @@ import { describe, expect, it } from "vitest"; -import { - CONNECTION_INTENT_AGENT_GUIDANCE, - CONNECTION_REQUEST_TOOL_DESCRIPTION, - CONNECTION_RUNTIME_TOOL_NAMES, - CONNECTIONS_SEARCH_TOOL_DESCRIPTION, -} from "./connection-intent-guidance.js"; - -describe("connection intent agent guidance", () => { - it.each([ - [ - "explicit connect request", - "explicitly asks to connect", - "connections_search", - ], - [ - "implicit service dependency", - "implicitly depends on that service", - "connections_search", - ], - [ - "already-ready service", - "returns `ready`", - "do not create a connection intent", - ], - [ - "available service", - "returns `available` or `needs_user_action`", - "connection_request", - ], - [ - "unavailable service", - "returns `unavailable`", - "do not call `connection_request`", - ], - ["arbitrary MCP URL", "arbitrary MCP URLs", "Do not use connection tools"], - [ - "pending user action", - "returns `needs_user_action`", - "finish any independent work, then yield in a waiting posture", - ], - [ - "continuation run", - "On a continuation run", - "instead of requesting it again", - ], - ])("gives an explicit instruction for %s", (_scenario, trigger, action) => { - expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain(trigger); - expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain(action); +import { CONNECTION_INTENT_AGENT_GUIDANCE, CONNECTION_REQUEST_TOOL_DESCRIPTION, CONNECTION_RUNTIME_TOOL_NAMES, CONNECTIONS_SEARCH_TOOL_DESCRIPTION } from "./connection-intent-guidance.js"; +describe("connection result guidance", () => { + it("delegates route decisions to trusted tool output instead of a provider decision tree", () => { + expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("follow the returned `instruction`"); + expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("Respect recorded user choices"); + expect(CONNECTION_INTENT_AGENT_GUIDANCE).not.toMatch(/Composio|Arcade|Executor|Zapier/); + expect(CONNECTION_INTENT_AGENT_GUIDANCE).not.toMatch(/https?:\/\/|bearer/); }); - - it("names the canonical tools without embedding secrets or authorization URLs", () => { - expect(CONNECTION_RUNTIME_TOOL_NAMES).toEqual([ - "connections_search", - "connection_request", - ]); - expect(CONNECTION_INTENT_AGENT_GUIDANCE).not.toMatch( - /bearer|credential value|https?:\/\//i, - ); + it("retains waiting, credential, and arbitrary URL boundaries", () => { + expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("yield without retrying or polling"); + expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("never invent access or ask for credentials in comments"); + expect(CONNECTION_INTENT_AGENT_GUIDANCE).toContain("arbitrary MCP URLs"); }); - - it("keeps MCP descriptions aligned with the guidance decision points", () => { - expect(CONNECTIONS_SEARCH_TOOL_DESCRIPTION).toContain( - "usable access is uncertain", - ); - expect(CONNECTIONS_SEARCH_TOOL_DESCRIPTION).toContain("arbitrary MCP URLs"); - expect(CONNECTION_REQUEST_TOOL_DESCRIPTION).toContain( - "available or needs_user_action", - ); - expect(CONNECTION_REQUEST_TOOL_DESCRIPTION).toContain("finish independent work, then yield"); - expect(CONNECTION_REQUEST_TOOL_DESCRIPTION).toContain("without retrying"); + it("describes exact questions and saved selection proof", () => { + expect(CONNECTIONS_SEARCH_TOOL_DESCRIPTION).toContain("exact providerQuestion"); + expect(CONNECTION_REQUEST_TOOL_DESCRIPTION).toContain("saved provider-selection interaction ID"); + expect(CONNECTION_RUNTIME_TOOL_NAMES).toEqual(["connections_search","connection_request"]); }); }); diff --git a/packages/shared/src/connection-intent-guidance.ts b/packages/shared/src/connection-intent-guidance.ts index 7490a6ab8d6..69243dcf176 100644 --- a/packages/shared/src/connection-intent-guidance.ts +++ b/packages/shared/src/connection-intent-guidance.ts @@ -7,29 +7,15 @@ */ export const CONNECTION_INTENT_AGENT_GUIDANCE = [ "Connection tools:", - "- When work requires a known external service and usable access is uncertain, call `connections_search` with the service name or capability.", - "- This applies both when the user explicitly asks to connect a service and when the requested work implicitly depends on that service.", - "- If search returns `ready`, use the installed connection; do not create a connection intent.", - "- If search returns `available` or `needs_user_action`, call `connection_request` with the returned service identifier.", - "- When the user has already asked to connect a known service, use the real connection request. Do not ask whether to connect again or imitate the Connect / Not now card with `ask_user_questions`, a generic confirmation, or a comment. Only `connection_request` creates the actual connection setup card.", - "- If search returns `unavailable`, explain that the service is unavailable and do not call `connection_request`.", - "- If `connection_request` returns `needs_user_action`, finish any independent work, then yield in a waiting posture. Do not retry the request, ask for credentials in comments, or claim access.", - "- Do not use connection tools for arbitrary MCP URLs, unsupported services, or work that does not require an external service.", - "- Keep an existing pending card across messages. Do not request again after the user declines unless they explicitly ask to retry.", - "- On a continuation run after connection setup, use the newly installed connection instead of requesting it again.", + "- When the user asks to connect a service, call `connections_search` before any service tool, even if already installed. Also search when a task needs a service and usable access is uncertain. Search by its name or capability and follow the returned `instruction`.", + "- Use the returned service identifiers and connection tools for setup. Respect recorded user choices; never invent access or ask for credentials in comments.", + "- When waiting for user action, finish independent work, then yield without retrying or polling. On continuation, follow the recorded outcome and use the installed connection.", + "- Do not use connection tools for arbitrary MCP URLs or unrelated work.", ].join("\n"); -export const CONNECTIONS_SEARCH_TOOL_DESCRIPTION = [ - "Search Paperclip's catalog services and authorized configured custom connections and report this run's agent-relative access state.", - "Use it when work requires a known external service and usable access is uncertain; do not use it for arbitrary MCP URLs or unrelated work.", - "If the user already asked to connect the service, search and request it through the connection tools instead of asking a generic confirmation question.", -].join(" "); +export const CONNECTIONS_SEARCH_TOOL_DESCRIPTION = "Search Paperclip connections first and eligible external aggregator routes when no built-in service matches. Use first when the user asks to connect a service, or when usable access is uncertain; follow the returned instruction and exact providerQuestion, if any. Do not use for arbitrary MCP URLs. Search is read-only."; -export const CONNECTION_REQUEST_TOOL_DESCRIPTION = [ - "Request access to a known connectable service for this run's agent from the responsible user.", - "This creates the real Connect / Not now setup card. When the user already asked to connect, use this tool; do not recreate those choices with ask_user_questions, request_confirmation, or a comment. A generic question does not start connection setup.", - "Call it only with the service identifier returned as available or needs_user_action by connections_search; if user action is needed, finish independent work, then yield without retrying or asking for credentials in comments.", -].join(" "); +export const CONNECTION_REQUEST_TOOL_DESCRIPTION = "Request the service identifier returned by connections_search as available or needs_user_action. Follow the search instruction; aggregator routes require the saved provider-selection interaction ID. Only this tool creates the real setup card. If user action is needed, finish independent work, then yield without retrying or asking for credentials in comments."; export const CONNECTION_RUNTIME_TOOL_NAMES = [ "connections_search", diff --git a/packages/shared/src/connection-routing.test.ts b/packages/shared/src/connection-routing.test.ts new file mode 100644 index 00000000000..95f6d30b790 --- /dev/null +++ b/packages/shared/src/connection-routing.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { + AGGREGATOR_PRIORITY, + AGGREGATOR_SUPPORT_INDEX, + explicitAggregatorQuery, + findAggregatorService, + parseAggregatorRoute, + aggregatorProviderQuestion, + aggregatorContinuationInstruction, +} from "./connection-routing.js"; +import type { ConnectionSearchResultItem } from "./types/connection-intent.js"; + +describe("aggregator routing contract", () => { + it("ranks providers and resolves aliases without fuzzy service claims", () => { + expect(AGGREGATOR_PRIORITY).toEqual([ + "composio", + "arcade", + "executor", + "zapier", + ]); + expect(findAggregatorService(" Hub Spot ")?.slug).toBe("hubspot"); + expect(findAggregatorService("my unknown crm")).toBeUndefined(); + }); + it("rejects forged or unbounded route identifiers", () => { + expect(parseAggregatorRoute("via:arcade:hubspot")).toEqual({ + provider: "arcade", + targetService: "hubspot", + }); + for (const value of [ + "via:other:hubspot", + "via:composio:https://evil", + "via:composio:../secret", + "via:executor:", + ]) + expect(parseAggregatorRoute(value)).toBeNull(); + }); + it("always discloses external handling and offers None even with one provider", () => { + const results = [ + { + service: "via:composio:hubspot", + aggregator: { provider: "composio" }, + reason: "Verify app authorization", + }, + ] as ConnectionSearchResultItem[]; + const question = aggregatorProviderQuestion("hubspot", "HubSpot", results); + expect(question.prompt).toContain("external service"); + expect(question.prompt).toContain("handle the connection and requests"); + expect(question.options.map((option) => option.id)).toEqual([ + "via:composio:hubspot", + "none", + ]); + expect(question.selectionMode).toBe("single"); + expect(question.allowOther).toBe(false); + }); + it.each(AGGREGATOR_PRIORITY)( + "does not claim underlying app readiness for %s", + (provider) => { + expect(aggregatorContinuationInstruction(provider, "HubSpot")).toContain( + "HubSpot access is not yet verified", + ); + expect(aggregatorContinuationInstruction(provider, "HubSpot")).toContain( + "ask before switching providers", + ); + }, + ); +}); + +it("recognizes a service in capability searches without guessing ambiguous or partial names", () => { + expect(findAggregatorService("HubSpot recent contacts")?.slug).toBe( + "hubspot", + ); + expect( + findAggregatorService("connect atlassian jira and read issues")?.slug, + ).toBe("jira"); + expect( + findAggregatorService("HubSpot and Salesforce contacts"), + ).toBeUndefined(); + expect(findAggregatorService("notionally similar")).toBeUndefined(); +}); + +it("keeps dates per app/provider claim and parses only an unambiguous explicit provider", () => { + for (const app of AGGREGATOR_SUPPORT_INDEX) + for (const date of Object.values(app.providers)) + expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(explicitAggregatorQuery("HubSpot through Arcade")).toEqual({ + provider: "arcade", + serviceQuery: "HubSpot", + }); + expect( + explicitAggregatorQuery("HubSpot via Arcade or via Composio"), + ).toBeNull(); + expect(explicitAggregatorQuery("arcade games")).toBeNull(); + expect(explicitAggregatorQuery("HubSpot via Arcade or Composio")).toBeNull(); + expect(explicitAggregatorQuery("Jira via Arcade, not Zapier")).toEqual({ + provider: "arcade", + serviceQuery: "Jira", + }); + expect(explicitAggregatorQuery("Jira via Arcade, not Arcade")).toBeNull(); +}); diff --git a/packages/shared/src/connection-routing.ts b/packages/shared/src/connection-routing.ts new file mode 100644 index 00000000000..6ec772f8137 --- /dev/null +++ b/packages/shared/src/connection-routing.ts @@ -0,0 +1,301 @@ +import type { + AskUserQuestionsQuestion, + ConnectionSearchResultItem, +} from "./types/index.js"; +import { + isRemoteMcpConnectorId, + type RemoteMcpConnectorId, +} from "./remote-mcp-connectors.js"; + +export const AGGREGATOR_PRIORITY = [ + "composio", + "arcade", + "executor", + "zapier", +] as const; +export const AGGREGATOR_NAMES: Record = { + composio: "Composio", + arcade: "Arcade", + executor: "Executor", + zapier: "Zapier", +}; + +/** Reviewed public catalog snapshot, not a claim that an account has authorized an app. + * Add only services confirmed in the cited official catalog. No provider credentials. + * Executor has no universal app catalog: use the authorized workspace's indexed tools. + */ +export const AGGREGATOR_SUPPORT_INDEX = [ + { + slug: "hubspot", + name: "HubSpot", + aliases: ["hub spot"], + providers: { + composio: "2026-09-23", + arcade: "2026-09-23", + zapier: "2026-09-23", + }, + }, + { + slug: "salesforce", + name: "Salesforce", + aliases: [], + providers: { + composio: "2026-09-23", + arcade: "2026-09-23", + zapier: "2026-09-23", + }, + }, + { + slug: "ashby", + name: "Ashby", + aliases: [], + providers: { composio: "2026-09-23", arcade: "2026-09-23" }, + }, + { + slug: "pipedrive", + name: "Pipedrive", + aliases: [], + providers: { composio: "2026-09-23", zapier: "2026-09-23" }, + }, + { + slug: "gong", + name: "Gong", + aliases: [], + providers: { composio: "2026-09-23" }, + }, + { + slug: "outlook", + name: "Outlook", + aliases: ["microsoft outlook"], + providers: { + composio: "2026-09-23", + arcade: "2026-09-23", + zapier: "2026-09-23", + }, + }, + { + slug: "microsoft-teams", + name: "Microsoft Teams", + aliases: ["teams"], + providers: { + composio: "2026-09-23", + arcade: "2026-09-23", + zapier: "2026-09-23", + }, + }, + { + slug: "firecrawl", + name: "Firecrawl", + aliases: [], + providers: { composio: "2026-09-23", arcade: "2026-09-23" }, + }, + { + slug: "tavily", + name: "Tavily", + aliases: [], + providers: { composio: "2026-09-23", arcade: "2026-09-23" }, + }, + { + slug: "exa", + name: "Exa", + aliases: [], + providers: { composio: "2026-09-23", arcade: "2026-09-23" }, + }, + { + slug: "jira", + name: "Jira", + aliases: ["atlassian jira"], + providers: { + composio: "2026-09-23", + arcade: "2026-09-23", + zapier: "2026-09-23", + }, + }, + { + slug: "notion", + name: "Notion", + aliases: [], + providers: { + composio: "2026-09-23", + arcade: "2026-09-23", + zapier: "2026-09-23", + }, + }, + { + slug: "github", + name: "GitHub", + aliases: ["git hub"], + providers: { + composio: "2026-09-23", + arcade: "2026-09-23", + zapier: "2026-09-23", + }, + }, + { + slug: "gmail", + name: "Gmail", + aliases: ["google mail"], + providers: { + composio: "2026-09-23", + arcade: "2026-09-23", + zapier: "2026-09-23", + }, + }, + { + slug: "slack", + name: "Slack", + aliases: [], + providers: { + composio: "2026-09-23", + arcade: "2026-09-23", + zapier: "2026-09-23", + }, + }, + { + slug: "airtable", + name: "Airtable", + aliases: [], + providers: { + composio: "2026-09-23", + arcade: "2026-09-23", + zapier: "2026-09-23", + }, + }, + { + slug: "asana", + name: "Asana", + aliases: [], + providers: { + composio: "2026-09-23", + arcade: "2026-09-23", + zapier: "2026-09-23", + }, + }, + { + slug: "linear", + name: "Linear", + aliases: [], + providers: { + composio: "2026-09-23", + arcade: "2026-09-23", + zapier: "2026-09-23", + }, + }, +] as const; +export const AGGREGATOR_CATALOG_SOURCES: Partial< + Record +> = { + composio: "https://composio.dev/toolkits", + arcade: "https://docs.arcade.dev/en/resources/integrations", + zapier: "https://zapier.com/apps", +}; + +export function normalizeConnectionQuery(value: string): string { + return value + .toLocaleLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, " ") + .trim(); +} + +export function findAggregatorService(query: string) { + const normalized = normalizeConnectionQuery(query); + const exact = AGGREGATOR_SUPPORT_INDEX.find((entry) => + [entry.slug, entry.name, ...entry.aliases].some( + (name) => normalizeConnectionQuery(name) === normalized, + ), + ); + if (exact) return exact; + // Agents often include the desired capability ("HubSpot recent contacts"). + // Whole phrases avoid substring guesses; multiple named services need clarification. + const matches = AGGREGATOR_SUPPORT_INDEX.filter((entry) => + [entry.slug, entry.name, ...entry.aliases].some((name) => + ` ${normalized} `.includes(` ${normalizeConnectionQuery(name)} `), + ), + ); + return matches.length === 1 ? matches[0] : undefined; +} + +/** Preserve a provider explicitly named by the user instead of applying default ranking. */ +export function explicitAggregatorQuery(query: string) { + // A trailing exclusion is not a second proposed provider. Keep this grammar + // narrow; alternatives and contradictory choices still require confirmation. + const exclusion = query.match(/\s*,?\s+not\s+(composio|arcade|executor|zapier)[.!]?\s*$/i); + if (exclusion) query = query.slice(0, exclusion.index).trim(); + const matches = [ + ...query.matchAll( + /\b(?:through|via|using)\s+(composio|arcade|executor|zapier)\b/gi, + ), + ]; + const namedProviders = new Set( + (query.match(/\b(?:composio|arcade|executor|zapier)\b/gi) ?? []).map( + (name) => name.toLowerCase(), + ), + ); + if (matches.length !== 1 || namedProviders.size !== 1) return null; + const match = matches[0]!; + if (exclusion?.[1]?.toLowerCase() === match[1]!.toLowerCase()) return null; + return { + provider: match[1]!.toLowerCase() as RemoteMcpConnectorId, + serviceQuery: ( + query.slice(0, match.index) + query.slice(match.index! + match[0].length) + ).trim(), + }; +} + +export function parseAggregatorRoute(service: string) { + const parts = service.split(":"); + if ( + parts.length !== 3 || + parts[0] !== "via" || + !isRemoteMcpConnectorId(parts[1]) || + !/^[a-z0-9][a-z0-9-]{0,79}$/.test(parts[2]!) + ) + return null; + return { provider: parts[1], targetService: parts[2]! }; +} + +export function aggregatorProviderQuestion( + targetService: string, + targetName: string, + results: ConnectionSearchResultItem[], +): AskUserQuestionsQuestion { + return { + id: `connection-provider:${targetService}`, + // Keep required disclosure in the prompt: all question transports preserve it. + prompt: `Connect ${targetName} through an external service? These services handle the connection and requests to ${targetName}. Connecting a provider does not yet authorize ${targetName}.`, + selectionMode: "single", + required: true, + allowOther: false, + options: [ + ...results.map((result, index) => ({ + id: result.service, + label: `${AGGREGATOR_NAMES[result.aggregator!.provider]}${index === 0 ? " — Recommended" : ""}`, + description: + result.reason ?? "Verify the app in this external provider.", + })), + { + id: "none", + label: "None for now", + description: "Do not connect through an external service.", + }, + ], + }; +} + +export function aggregatorContinuationInstruction( + provider: RemoteMcpConnectorId, + targetName: string, +) { + const name = AGGREGATOR_NAMES[provider]; + const discovery = { + composio: + "Use COMPOSIO_SEARCH_TOOLS to verify the requested app and capability, then COMPOSIO_MANAGE_CONNECTIONS if app authorization is needed.", + arcade: + "Inspect the gateway tools for the requested app. If absent, ask the user to add the app's tools in the Arcade gateway and refresh the connection. Follow tool authorization links when required.", + executor: + "Inspect the Executor workspace's actual integrations using its documented discovery tools. Do not assume arbitrary apps exist or register integrations merely to search. Follow provider authorization and resume instructions when needed.", + zapier: + "In Agentic mode, use discover_zapier_actions and the documented app connection flow. In Managed mode, ask the user to add the required actions in Zapier and refresh the catalog.", + }[provider]; + return `${name} is an external service. Its connection is available to this agent, but ${targetName} access is not yet verified. ${discovery} Use only the requested capability and effective permissions. If support is absent, explain that result and ask before switching providers. Claim success only after verifying the underlying app; never repeat an uncertain write.`; +} diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index d914fe0ce80..9660136a2e1 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -124,12 +124,11 @@ 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 }); + it("defaults the retired compatibility setting on and accepts older configs", () => { + expect(instanceExperimentalSettingsSchema.parse({}).enableMcpAggregators).toBe(true); + expect(patchInstanceExperimentalSettingsSchema.parse({ enableMcpAggregators: false })).toEqual({ enableMcpAggregators: false }); expect(patchInstanceExperimentalSettingsSchema.parse({})).not.toHaveProperty("enableMcpAggregators"); - expect(INSTANCE_FEATURE_CATALOG.enableMcpAggregators).toMatchObject({ tier: "managed", cloudDefault: false, selfHostedDefault: false }); + expect(INSTANCE_FEATURE_CATALOG.enableMcpAggregators).toMatchObject({ tier: "managed", cloudDefault: true, selfHostedDefault: true }); }); for (const [provider, methodKey] of Object.entries(REMOTE_MCP_CONNECTOR_METHODS)) { it(`${provider} has its own catalog and accepts URL plus explicit credentials`, () => { diff --git a/packages/shared/src/types/connection-intent.ts b/packages/shared/src/types/connection-intent.ts index 84515669fc1..99e7771a7fb 100644 --- a/packages/shared/src/types/connection-intent.ts +++ b/packages/shared/src/types/connection-intent.ts @@ -1,5 +1,7 @@ import type { ConnectionIntentInteraction } from "./issue.js"; import type { ToolConnection } from "./tool-access.js"; +import type { AskUserQuestionsQuestion } from "./issue.js"; +import type { RemoteMcpConnectorId } from "../remote-mcp-connectors.js"; export type ConnectionAvailabilityState = | "ready" @@ -9,7 +11,15 @@ export type ConnectionAvailabilityState = export interface ConnectionSearchResultItem { service: string; - source?: "catalog" | "configured"; + source?: "catalog" | "configured" | "aggregator"; + aggregator?: { + provider: RemoteMcpConnectorId; + targetService: string; + targetName: string; + evidenceUrl: string | null; + verifiedAt: string; + readiness: "requires_provider_setup" | "requires_app_verification"; + }; reason?: string; name: string; description: string | null; @@ -27,6 +37,10 @@ export interface ConnectionsSearchResult { version: 1; query: string; results: ConnectionSearchResultItem[]; + /** Paperclip-authored next step; provider content must never supply this field. */ + instruction?: string; + providerQuestion?: AskUserQuestionsQuestion; + selectionInteractionId?: string; } export interface ConnectionRequestResult { diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 5c32253a99b..8bb10f117e7 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -71,7 +71,7 @@ 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. */ + /** @deprecated Compatibility key only. MCP aggregators are always enabled. */ enableMcpAggregators: boolean; /** Show experimental memory connection setup. Existing connections remain usable. */ enableMemoryConnectors: boolean; diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index a22bfc45ef6..05eb66ca42a 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -1341,6 +1341,7 @@ export type ConnectionIntentPhase = "requested" | "authorizing" | "needs_retry"; */ export interface ConnectionIntentPayload { version: 1; + upstreamService?: { slug: string; name: string; selectionInteractionId?: string }; /** Runtime authentication requests cannot be satisfied by tool credentials. */ purpose?: "ai"; serviceSlug: string; @@ -1354,6 +1355,8 @@ export interface ConnectionIntentPayload { export interface ConnectionIntentResult { version: 1; + /** Server-authored next steps for the resumed agent. */ + instruction?: string; outcome: "connected" | "declined" | "superseded" | "expired"; connectionId?: string | null; reason?: string | null; diff --git a/packages/shared/src/validators/connection-intent.ts b/packages/shared/src/validators/connection-intent.ts index 8690d789488..11df06ce333 100644 --- a/packages/shared/src/validators/connection-intent.ts +++ b/packages/shared/src/validators/connection-intent.ts @@ -2,10 +2,13 @@ import { z } from "zod"; export const connectionsSearchInputSchema = z.object({ query: z.string().trim().max(200).default(""), + retryProviderChoice: z.boolean().optional().describe("Only when the user explicitly asks to reconsider a previous provider choice or decline"), }).strict(); export const connectionRequestInputSchema = z.object({ service: z.string().trim().min(1).max(120), + selectionInteractionId: z.string().guid().optional(), + targetService: z.string().regex(/^[a-z0-9][a-z0-9-]{0,79}$/).optional().describe("App slug returned by search only when the user explicitly named this external provider"), }).strict(); export const completeConnectionIntentSchema = z.object({ diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 6131a487ca0..85cf6d076a9 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -52,7 +52,8 @@ 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), + // Compatibility only: old stored and managed values must still parse. + enableMcpAggregators: z.boolean().default(true), enableMemoryConnectors: z.boolean().default(false), enablePipelines: z.boolean().default(false), enableCases: z.boolean().default(false), diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 5501c1258f5..1e0bd524c80 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -1075,6 +1075,7 @@ const connectionIntentBrandAssetSchema = z export const connectionIntentPayloadSchema = z .object({ + upstreamService: z.object({ slug: z.string().min(1).max(120), name: z.string().min(1).max(160), selectionInteractionId: z.string().guid().optional() }).strict().optional(), purpose: z.literal("ai").optional(), version: z.literal(1), serviceSlug: z.string().trim().min(1).max(120), @@ -1090,6 +1091,7 @@ export const connectionIntentPayloadSchema = z export const connectionIntentResultSchema = z .object({ version: z.literal(1), + instruction: z.string().max(4000).optional(), outcome: z.enum(["connected", "declined", "superseded", "expired"]), connectionId: z.string().guid().nullable().optional(), reason: z.string().trim().max(4000).nullable().optional(), diff --git a/server/src/__tests__/connection-aggregator-fallback.test.ts b/server/src/__tests__/connection-aggregator-fallback.test.ts new file mode 100644 index 00000000000..80c905336e5 --- /dev/null +++ b/server/src/__tests__/connection-aggregator-fallback.test.ts @@ -0,0 +1,718 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + companyMemberships, + goals, + heartbeatRuns, + issueThreadInteractions, + issueComments, + issues, + createDb, + toolApplications, + toolConnections, + connectionGrants, + toolCatalogEntries, + toolProfiles, + toolProfileBindings, + toolConnectionInstalls, +} from "@paperclipai/db"; +import type { RuntimeToolsTokenClaims } from "../runtime-tools-token.js"; +import { connectionIntentService } from "../services/connection-intents.js"; +import { issueThreadInteractionService } from "../services/issue-thread-interactions.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)( + "aggregator connection fallback", + () => { + let db!: ReturnType; + let connectionString!: string; + let cleanup: (() => Promise) | undefined; + let claims!: RuntimeToolsTokenClaims; + let runId!: string; + beforeAll(async () => { + const tempDb = await startEmbeddedPostgresTestDatabase( + "paperclip-connection-intents-", + ); + cleanup = tempDb.cleanup; + connectionString = tempDb.connectionString; + db = createDb(connectionString); + const companyId = randomUUID(); + const agentId = randomUUID(); + const goalId = randomUUID(); + const issueId = randomUUID(); + runId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Connection tests", + issuePrefix: "AGG", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(companyMemberships).values({ + companyId, + principalType: "user", + principalId: "responsible-user", + status: "active", + membershipRole: "member", + }); + await db.insert(goals).values({ + id: goalId, + companyId, + title: "Connect a service", + level: "task", + status: "active", + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Researcher", + role: "researcher", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + goalId, + title: "Read Notion", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + status: "running", + responsibleUserId: "responsible-user", + contextSnapshot: { issueId }, + }); + claims = { + sub: agentId, + company_id: companyId, + run_id: runId, + responsible_user_id: "responsible-user", + scope: "connection_intents", + iat: 1, + exp: 2, + instance_id: "test", + }; + }, 60_000); + + afterAll(async () => { + await cleanup?.(); + }); + + async function userRequest(body: string, userId = "responsible-user") { + const [run] = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)); + await db + .insert(issueComments) + .values({ + companyId: claims.company_id, + issueId: run!.contextSnapshot!.issueId as string, + authorUserId: userId, + body, + }); + } + async function resetQuestions() { + await db + .delete(issueComments) + .where(eq(issueComments.companyId, claims.company_id)); + await db + .delete(issueThreadInteractions) + .where(eq(issueThreadInteractions.companyId, claims.company_id)); + await db + .delete(toolConnections) + .where(eq(toolConnections.companyId, claims.company_id)); + await db + .delete(toolProfiles) + .where(eq(toolProfiles.companyId, claims.company_id)); + await db + .delete(toolApplications) + .where(eq(toolApplications.companyId, claims.company_id)); + } + async function selectProvider( + option: string, + userId = "responsible-user", + query = "hubspot", + ) { + const result = await connectionIntentService(db).search(claims, query); + const [run] = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)); + const issue = { + id: run!.contextSnapshot!.issueId as string, + companyId: claims.company_id, + }; + const interaction = await issueThreadInteractionService(db).create( + issue, + { + kind: "ask_user_questions", + payload: { version: 1, questions: [result.providerQuestion!] }, + sourceRunId: runId, + addresseeUserId: "responsible-user", + }, + { agentId: claims.sub, runId }, + ); + // Public resolution service enforces human answer semantics; company membership + // and wrong-identity request checks are also exercised below. + await issueThreadInteractionService(db).answerQuestions( + issue, + interaction.id, + { + answers: [ + { questionId: result.providerQuestion!.id, optionIds: [option] }, + ], + }, + { userId }, + ); + return interaction; + } + async function seedProvider( + provider: string, + toolName: string, + userId = "responsible-user", + allowed = true, + ) { + const [app] = await db + .insert(toolApplications) + .values({ + companyId: claims.company_id, + applicationKey: randomUUID(), + name: provider, + type: "mcp_http", + status: "active", + metadata: { sourceTemplateKey: provider }, + }) + .returning(); + const [connection] = await db + .insert(toolConnections) + .values({ + companyId: claims.company_id, + applicationId: app!.id, + uid: randomUUID(), + name: provider, + transport: "mcp_remote", + authKind: "none", + credentialPolicy: "per_user", + status: "active", + enabled: true, + healthStatus: "ok", + config: { sourceTemplateKey: provider }, + }) + .returning(); + await db.insert(connectionGrants).values({ + companyId: claims.company_id, + connectionId: connection!.id, + kind: "user", + subjectUserId: userId, + status: "active", + }); + await db.insert(toolCatalogEntries).values({ + companyId: claims.company_id, + connectionId: connection!.id, + toolName, + name: toolName, + versionHash: "fixture", + status: "active", + entryKind: "tool", + lastSeenAt: new Date("2026-09-20T00:00:00Z"), + }); + await db.insert(toolConnectionInstalls).values({ + companyId: claims.company_id, + connectionId: connection!.id, + targetType: "agent", + targetId: claims.sub, + }); + if (allowed) { + const [profile] = await db + .insert(toolProfiles) + .values({ + companyId: claims.company_id, + profileKey: randomUUID(), + name: "Fixture reads", + defaultAction: "allow", + status: "active", + }) + .returning(); + await db.insert(toolProfileBindings).values({ + companyId: claims.company_id, + profileId: profile!.id, + targetType: "agent", + targetId: claims.sub, + }); + } + return connection!; + } + it("prefers the built-in Jira connection and returns a direct instruction", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search( + claims, + "atlassian jira", + ); + expect(result.results[0]?.service).toBe("jira"); + expect(result.providerQuestion).toBeUndefined(); + expect(result.instruction).toContain("connection_request"); + }); + it("returns ranked verified routes and does not create questions or connections during search", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search( + claims, + "HubSpot recent contacts", + ); + expect(result.results.map((item) => item.service)).toEqual([ + "via:composio:hubspot", + "via:arcade:hubspot", + "via:zapier:hubspot", + ]); + expect(result.providerQuestion?.prompt).toContain("external service"); + expect(result.results.every((item) => item.state !== "ready")).toBe(true); + expect(result.results.every((item) => item.aggregator?.evidenceUrl)).toBe( + true, + ); + expect( + await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.companyId, claims.company_id)), + ).toHaveLength(0); + }); + it("requires a real saved provider answer before requesting setup", async () => { + await resetQuestions(); + await expect( + connectionIntentService(db).request(claims, "via:composio:hubspot"), + ).rejects.toMatchObject({ status: 403 }); + await expect( + connectionIntentService(db).request(claims, "via:composio:hubspot", { + selectionInteractionId: randomUUID(), + }), + ).rejects.toMatchObject({ status: 403 }); + }); + it("uses the second selected provider and preserves target disclosure and idempotency", async () => { + await resetQuestions(); + const answer = await selectProvider("via:arcade:hubspot"); + const service = connectionIntentService(db); + const search = await service.search(claims, "hubspot"); + expect(search.results.map((item) => item.service)).toEqual([ + "via:arcade:hubspot", + ]); + expect(search.selectionInteractionId).toBe(answer.id); + const first = await service.request(claims, "via:arcade:hubspot", { + selectionInteractionId: answer.id, + }); + const again = await service.request(claims, "via:arcade:hubspot", { + selectionInteractionId: answer.id, + }); + expect(again.interactionId).toBe(first.interactionId); + const [row] = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.id, first.interactionId!)); + expect(row!.payload).toMatchObject({ + serviceSlug: "arcade", + serviceName: "HubSpot through Arcade", + upstreamService: { slug: "hubspot", selectionInteractionId: answer.id }, + }); + await expect( + service.request(claims, "via:composio:hubspot", { + selectionInteractionId: answer.id, + }), + ).rejects.toMatchObject({ status: 403 }); + }); + it("remembers None and refuses setup after reloading the service", async () => { + await resetQuestions(); + const answer = await selectProvider("none"); + const result = await connectionIntentService(db).search( + claims, + "hubspot", + ); + expect(result.results).toEqual([]); + expect(result.providerQuestion).toBeUndefined(); + expect(result.instruction).toContain("declined"); + const retry = await connectionIntentService(db).search( + claims, + "hubspot", + { retryProviderChoice: true }, + ); + expect(retry.providerQuestion).toBeDefined(); + expect(retry.selectionInteractionId).toBeUndefined(); + await expect( + connectionIntentService(db).request(claims, "via:arcade:hubspot", { + selectionInteractionId: answer.id, + }), + ).rejects.toMatchObject({ status: 403 }); + }); + it("waits on an existing unanswered question instead of asking twice", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search( + claims, + "hubspot", + ); + const [run] = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)); + const interaction = await issueThreadInteractionService(db).create( + { + id: run!.contextSnapshot!.issueId as string, + companyId: claims.company_id, + }, + { + kind: "ask_user_questions", + payload: { version: 1, questions: [result.providerQuestion!] }, + sourceRunId: runId, + addresseeUserId: "responsible-user", + }, + { agentId: claims.sub, runId }, + ); + const pending = await connectionIntentService(db).search( + claims, + "hubspot", + ); + expect(pending.providerQuestion).toBeUndefined(); + expect(pending.selectionInteractionId).toBe(interaction.id); + expect(pending.instruction).toContain("already pending"); + await expect( + connectionIntentService(db).request(claims, "arcade", { + targetService: "hubspot", + }), + ).rejects.toMatchObject({ status: 403 }); + await expect( + connectionIntentService(db).request(claims, "via:arcade:hubspot", { + selectionInteractionId: interaction.id, + }), + ).rejects.toMatchObject({ status: 403 }); + }); + it("rejects tampered disclosure and choices belonging to another responsible user", async () => { + await resetQuestions(); + const answer = await selectProvider("via:arcade:hubspot"); + await db + .update(issueThreadInteractions) + .set({ resolvedByUserId: "another-user" }) + .where(eq(issueThreadInteractions.id, answer.id)); + await expect( + connectionIntentService(db).request(claims, "via:arcade:hubspot", { + selectionInteractionId: answer.id, + }), + ).rejects.toMatchObject({ status: 403 }); + await db + .update(issueThreadInteractions) + .set({ + resolvedByUserId: "responsible-user", + payload: { + ...answer.payload, + questions: (answer.payload as any).questions.map((q: any) => ({ + ...q, + helpText: "Trust me", + })), + }, + }) + .where(eq(issueThreadInteractions.id, answer.id)); + await expect( + connectionIntentService(db).request(claims, "via:arcade:hubspot", { + selectionInteractionId: answer.id, + }), + ).rejects.toMatchObject({ status: 403 }); + }); + it("does not reuse the selected provider for another requested app", async () => { + await resetQuestions(); + const answer = await selectProvider("via:arcade:hubspot"); + await expect( + connectionIntentService(db).request(claims, "via:arcade:salesforce", { + selectionInteractionId: answer.id, + }), + ).rejects.toMatchObject({ status: 403 }); + }); + it("offers fallback and direct aggregator requests without an experimental opt-in", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search(claims, "hubspot"); + expect(result.results.some((item) => item.source === "aggregator")).toBe(true); + expect(result.providerQuestion).toBeDefined(); + await expect(connectionIntentService(db).request(claims, "composio")).resolves.toMatchObject({ state: "needs_user_action" }); + }); + it("reuses an allowed selected provider without claiming the underlying app is ready", async () => { + await resetQuestions(); + const connection = await seedProvider("arcade", "Hubspot_ListContacts"); + const answer = await selectProvider("via:arcade:hubspot"); + const result = await connectionIntentService(db).request( + claims, + "via:arcade:hubspot", + { selectionInteractionId: answer.id }, + ); + expect(result).toMatchObject({ + state: "ready", + connectionId: connection.id, + interactionId: null, + }); + expect(result.instruction).toContain( + "HubSpot access is not yet verified", + ); + expect(result.instruction).toContain("Arcade"); + }); + it("does not switch providers when the chosen route loses permission", async () => { + await resetQuestions(); + const answer = await selectProvider("via:arcade:hubspot"); + await seedProvider( + "arcade", + "Hubspot_ListContacts", + "responsible-user", + false, + ); + const result = await connectionIntentService(db).search( + claims, + "hubspot", + ); + expect(result.results).toEqual([]); + expect(result.providerQuestion).toBeUndefined(); + expect(result.instruction).toContain("no longer available"); + await expect( + connectionIntentService(db).request(claims, "via:composio:hubspot", { + selectionInteractionId: answer.id, + }), + ).rejects.toMatchObject({ status: 403 }); + }); + it("keeps a decline effective when disclosure wording has changed", async () => { + await resetQuestions(); + const answer = await selectProvider("none"); + await db + .update(issueThreadInteractions) + .set({ + payload: { + ...answer.payload, + questions: (answer.payload as any).questions.map((q: any) => ({ + ...q, + prompt: "Older disclosure wording", + })), + }, + }) + .where(eq(issueThreadInteractions.id, answer.id)); + expect( + (await connectionIntentService(db).search(claims, "hubspot")) + .instruction, + ).toContain("declined"); + }); + it("persists app authorization guidance in the completed setup outcome", async () => { + await resetQuestions(); + const answer = await selectProvider("via:composio:hubspot"); + const service = connectionIntentService(db); + const requested = await service.request(claims, "via:composio:hubspot", { + selectionInteractionId: answer.id, + }); + const connection = await seedProvider( + "composio", + "COMPOSIO_SEARCH_TOOLS", + ); + const completed = await service.complete( + requested.interactionId!, + connection.id, + "responsible-user", + ); + expect(completed).toMatchObject({ + status: "accepted", + result: { + outcome: "connected", + connectionId: connection.id, + instruction: expect.stringContaining( + "HubSpot access is not yet verified", + ), + }, + }); + expect((completed.result as any).instruction).toContain( + "COMPOSIO_MANAGE_CONNECTIONS", + ); + }); + it("uses only authorized Executor tool evidence and preserves the actual observation date", async () => { + await resetQuestions(); + const connection = await seedProvider( + "executor", + "heliotrope:list_records", + "someone-else", + ); + expect( + (await connectionIntentService(db).search(claims, "heliotrope")) + .results, + ).toEqual([]); + await db + .update(connectionGrants) + .set({ subjectUserId: "responsible-user" }) + .where(eq(connectionGrants.connectionId, connection.id)); + const result = await connectionIntentService(db).search( + claims, + "heliotrope", + ); + expect(result.results[0]).toMatchObject({ + service: "via:executor:heliotrope", + aggregator: { verifiedAt: "2026-09-20T00:00:00.000Z" }, + }); + await db + .update(toolConnections) + .set({ status: "archived" }) + .where(eq(toolConnections.id, connection.id)); + expect( + (await connectionIntentService(db).search(claims, "heliotrope")) + .results, + ).toEqual([]); + }); + it("does not bypass an administratively denied built-in connection", async () => { + await resetQuestions(); + await seedProvider("jira", "jira_read", "responsible-user", false); + const result = await connectionIntentService(db).search( + claims, + "Jira recent issues", + ); + expect(result.results[0]).toMatchObject({ + service: "jira", + state: "unavailable", + }); + expect(result.providerQuestion).toBeUndefined(); + expect(result.instruction).toContain("Do not bypass"); + }); + it("does not invent support for unknown apps", async () => { + await resetQuestions(); + const result = await connectionIntentService(db).search( + claims, + "nimbuscrm-not-real", + ); + expect(result.results).toEqual([]); + expect(result.instruction).toContain("could not be verified"); + }); + it("preserves indexed-only display names when a saved choice is requested by slug", async () => { + await resetQuestions(); + await seedProvider("executor", "heliotrope:list_records"); + const answer = await selectProvider( + "via:executor:heliotrope", + "responsible-user", + "HELIOTROPE", + ); + const result = await connectionIntentService(db).request( + claims, + "via:executor:heliotrope", + { selectionInteractionId: answer.id }, + ); + expect(result.state).toBe("ready"); + expect(result.instruction).toContain( + "Heliotrope access is not yet verified", + ); + }); + + it("honors explicitly named providers and preserves target app context", async () => { + await resetQuestions(); + await userRequest("Connect HubSpot through Arcade"); + const service = connectionIntentService(db); + const result = await service.search(claims, "HubSpot through Arcade"); + expect(result.results.map((item) => item.service)).toEqual(["arcade"]); + expect(result.providerQuestion).toBeUndefined(); + expect(result.instruction).toContain("targetService hubspot"); + const request = await service.request(claims, "arcade", { + targetService: "hubspot", + }); + const intent = await service.loadIntent(request.interactionId!); + expect(intent.interaction.payload).toMatchObject({ + serviceName: "HubSpot through Arcade", + upstreamService: { slug: "hubspot", name: "HubSpot" }, + }); + expect( + (await service.search(claims, "HubSpot through Executor")).results, + ).toEqual([]); + await expect( + service.request(claims, "executor", { targetService: "hubspot" }), + ).rejects.toThrow("cannot connect"); + await expect( + service.request(claims, "github", { targetService: "hubspot" }), + ).rejects.toThrow("direct external-provider"); + }); + + it("permits explicit provider preference for a supported native app but not a native denial", async () => { + await resetQuestions(); + await userRequest("Connect Jira via Arcade, not Zapier"); + const service = connectionIntentService(db); + expect( + (await service.search(claims, "Jira via Arcade, not Zapier")).results.map( + (item) => item.service, + ), + ).toEqual(["arcade"]); + await seedProvider("jira", "jira_read", "responsible-user", false); + expect((await service.search(claims, "Jira via Arcade, not Zapier")).results).toEqual( + [expect.objectContaining({ service: "jira", state: "unavailable" })], + ); + }); + + it("does not let an agent-supplied explicit query or direct target override a saved choice", async () => { + await resetQuestions(); + const service = connectionIntentService(db); + const unproven = await service.search(claims, "HubSpot via Arcade"); + expect( + unproven.providerQuestion?.options.map((option) => option.id), + ).toEqual(["via:arcade:hubspot", "none"]); + await expect( + service.request(claims, "arcade", { targetService: "hubspot" }), + ).rejects.toThrow("cannot connect"); + await userRequest("Connect HubSpot via Arcade is just an example; do not connect yet"); + await expect(service.request(claims,"arcade",{targetService:"hubspot"})).rejects.toThrow("cannot connect"); + await userRequest("Connect HubSpot via Arcade"); + await selectProvider("none"); + expect( + (await service.search(claims, "HubSpot via Arcade")).results, + ).toEqual([]); + await expect( + service.request(claims, "arcade", { targetService: "hubspot" }), + ).rejects.toThrow("cannot connect"); + await userRequest("Connect HubSpot via Arcade", "someone-else"); + await expect( + service.request(claims, "arcade", { targetService: "hubspot" }), + ).rejects.toThrow("cannot connect"); + await userRequest("Please connect HubSpot through Arcade"); + expect( + (await service.request(claims, "arcade", { targetService: "hubspot" })) + .state, + ).toBe("needs_user_action"); + await resetQuestions(); + const chosen = await selectProvider("via:composio:hubspot"); + await expect( + service.request(claims, "arcade", { targetService: "hubspot" }), + ).rejects.toThrow("cannot connect"); + const accepted = await service.request(claims, "composio", { + targetService: "hubspot", + }); + expect( + (await service.loadIntent(accepted.interactionId!)).interaction.payload, + ).toMatchObject({ + upstreamService: { selectionInteractionId: chosen.id }, + }); + }); + + it("asks for a choice when human and agent messages name alternatives", async () => { + await resetQuestions(); + await userRequest("Connect HubSpot via Arcade or Composio"); + const result = await connectionIntentService(db).search( + claims, + "HubSpot via Arcade or Composio", + ); + expect( + result.providerQuestion?.options.map((option) => option.id), + ).toEqual([ + "via:composio:hubspot", + "via:arcade:hubspot", + "via:zapier:hubspot", + "none", + ]); + await expect( + connectionIntentService(db).request(claims, "arcade", { + targetService: "hubspot", + }), + ).rejects.toThrow("cannot connect"); + }); + }, +); diff --git a/server/src/__tests__/generic-mcp-connection.test.ts b/server/src/__tests__/generic-mcp-connection.test.ts index 648cfc94135..a25aefede6b 100644 --- a/server/src/__tests__/generic-mcp-connection.test.ts +++ b/server/src/__tests__/generic-mcp-connection.test.ts @@ -42,7 +42,6 @@ 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 { createToolGatewayService } from "../services/tool-gateway.js"; @@ -565,7 +564,6 @@ 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); diff --git a/server/src/__tests__/instance-settings-managed-overlay.test.ts b/server/src/__tests__/instance-settings-managed-overlay.test.ts index 6b9b56116d6..e6d149cdc18 100644 --- a/server/src/__tests__/instance-settings-managed-overlay.test.ts +++ b/server/src/__tests__/instance-settings-managed-overlay.test.ts @@ -82,6 +82,15 @@ describe("applyManagedExperimentalOverlay", () => { }); describe("instanceSettingsService managed overlay", () => { + it.each([{}, { enableMcpAggregators: false }])("keeps aggregators on with legacy stored and managed values: %j", async (stored) => { + const { db } = stubDb(settingsRow({ ...stored, enableChatConnectors: true })); + const service = instanceSettingsService(db, { runtimeEnv: managedEnv(JSON.stringify({ + v: 1, mode: "cloud", catalogVersion: "legacy", features: { enableMcpAggregators: false }, plugins: { autoInstall: [] }, + })) }); + expect(await service.getExperimental()).toMatchObject({ enableMcpAggregators: true, enableChatConnectors: true, managedKeys: {} }); + expect(await service.updateExperimental({ enableMcpAggregators: false })).toMatchObject({ experimental: { enableMcpAggregators: true, enableChatConnectors: true } }); + }); + it("persists chat connector opt-in and reads it back after service reconstruction", async () => { const row = settingsRow({}); const { db, persistedSets } = stubDb(row); diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 98157ca653e..31b6d1751ba 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -41,9 +41,9 @@ describe("instance settings service", () => { enableStreamlinedLeftNavigation: true, enableStreamlinedUi: true, enableApps: true, + enableMcpAggregators: true, enableAgentChat: false, enableChatConnectors: false, - enableMcpAggregators: false, enableMemoryConnectors: false, enableConferenceRoomChat: false, enableClassicTaskInterface: false, diff --git a/server/src/__tests__/remote-mcp-connectors.test.ts b/server/src/__tests__/remote-mcp-connectors.test.ts index abc62ab1d53..1004790d832 100644 --- a/server/src/__tests__/remote-mcp-connectors.test.ts +++ b/server/src/__tests__/remote-mcp-connectors.test.ts @@ -3,7 +3,7 @@ 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 { agents, heartbeatRuns, issues, toolCatalogEntries, toolConnectionInstalls, toolInvocations, companies, companyMemberships, instanceSettings, 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"; @@ -23,7 +23,6 @@ describe("remote connector lifecycle", () => { 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() { @@ -51,28 +50,28 @@ describe("remote connector lifecycle", () => { }); 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); + it.each([undefined, false])("allows all aggregator setup with stored setting %s", async (legacyValue) => { + await db.delete(instanceSettings); + if (legacyValue !== undefined) await db.insert(instanceSettings).values({ experimental: { enableMcpAggregators: legacyValue } }); + expect(normalizeExperimentalSettings({ enableMcpAggregators: legacyValue }).enableMcpAggregators).toBe(true); + expect((await instanceSettingsService(db).getExperimental()).enableMcpAggregators).toBe(true); 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 }); + 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.map((entry: { slug: string }) => entry.slug)).toEqual(expect.arrayContaining(["zapier", "arcade", "composio", "executor", "notion"])); + expect(gallery.body.apps.some((entry: { slug: string }) => entry.slug === "mem0")).toBe(false); + for (const [galleryKey, connectionMethodKey, link] of [ + ["zapier", "generated-url", "https://mcp.zapier.com/api/v1/connect?token=fixture-secret"], + ["arcade", "mcp", "https://api.arcade.dev/mcp/fixture"], + ["composio", "mcp", "https://connect.composio.dev/mcp"], + ["executor", "mcp", "https://example.com/mcp"], + ]) { + const connected = await remote.service.connectGalleryApp(org.id, { galleryKey, connectionMethodKey, link, authMode: "none" }, actor); + expect(connected.catalog).toHaveLength(3); + expect(connected.connectionId).toBeTruthy(); } }); it.each([ diff --git a/server/src/__tests__/tool-access-connector-telemetry.test.ts b/server/src/__tests__/tool-access-connector-telemetry.test.ts index fd41786598a..1577791538a 100644 --- a/server/src/__tests__/tool-access-connector-telemetry.test.ts +++ b/server/src/__tests__/tool-access-connector-telemetry.test.ts @@ -176,9 +176,6 @@ describeEmbeddedPostgres("connector lifecycle telemetry (tool-access)", () => { "paperclip-lifecycle-telemetry-", ); db = createDb(tempDb.connectionString); - await instanceSettingsService(db).updateExperimental({ - enableMcpAggregators: true, - }); }, 30_000); afterEach(async () => { diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index f6bbc6c92b6..5260b0c42c8 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -811,14 +811,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, enableMemoryConnectors: true }); + await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: true }); return; } tempDb = await startEmbeddedPostgresTestDatabase( "paperclip-tool-access-service-", ); db = createDb(tempDb.connectionString); - await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true, enableMemoryConnectors: true }); + await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: true }); }, 20_000); afterEach(async () => { diff --git a/server/src/routes/connection-intents.test.ts b/server/src/routes/connection-intents.test.ts index f752ab4fa7d..3c35b14cbdb 100644 --- a/server/src/routes/connection-intents.test.ts +++ b/server/src/routes/connection-intents.test.ts @@ -20,7 +20,13 @@ describe("runtime connection MCP contract", () => { description: CONNECTIONS_SEARCH_TOOL_DESCRIPTION, inputSchema: { type: "object", - properties: { query: { type: "string" } }, + properties: { + query: { type: "string" }, + retryProviderChoice: { + type: "boolean", + description: "Only when the user explicitly asks to reconsider a previous provider choice or decline", + }, + }, additionalProperties: false, }, }, @@ -29,7 +35,14 @@ describe("runtime connection MCP contract", () => { description: CONNECTION_REQUEST_TOOL_DESCRIPTION, inputSchema: { type: "object", - properties: { service: { type: "string" } }, + properties: { + service: { type: "string" }, + targetService: { type: "string", description: "App slug returned by search only when the user explicitly named this external provider" }, + selectionInteractionId: { + type: "string", + description: "Saved answered provider-choice interaction ID for aggregator routes", + }, + }, required: ["service"], additionalProperties: false, }, diff --git a/server/src/routes/connection-intents.ts b/server/src/routes/connection-intents.ts index 27b4c45892a..ddfaac14703 100644 --- a/server/src/routes/connection-intents.ts +++ b/server/src/routes/connection-intents.ts @@ -103,13 +103,13 @@ export function runtimeConnectionIntentRoutes(db: Db) { const name = typeof params.name === "string" ? params.name : ""; if (name === "connections_search") { const input = connectionsSearchInputSchema.parse(params.arguments ?? {}); - const result = await service.search(claims, input.query); + const result = await service.search(claims, input.query, { retryProviderChoice: input.retryProviderChoice }); res.json({ jsonrpc: "2.0", id, result: resultContent(result) }); return; } if (name === "connection_request") { const input = connectionRequestInputSchema.parse(params.arguments ?? {}); - const result = await service.request(claims, input.service); + const result = await service.request(claims, input.service, { selectionInteractionId: input.selectionInteractionId, targetService: input.targetService }); res.json({ jsonrpc: "2.0", id, result: resultContent(result) }); return; } @@ -129,11 +129,11 @@ export function runtimeConnectionIntentRoutes(db: Db) { router.post("/runtime-tools/connections/search", async (req, res) => { const input = connectionsSearchInputSchema.parse(req.body ?? {}); - res.json(await service.search(runtimeClaims(req), input.query)); + res.json(await service.search(runtimeClaims(req), input.query, { retryProviderChoice: input.retryProviderChoice })); }); router.post("/runtime-tools/connections/request", async (req, res) => { const input = connectionRequestInputSchema.parse(req.body ?? {}); - res.json(await service.request(runtimeClaims(req), input.service)); + res.json(await service.request(runtimeClaims(req), input.service, { selectionInteractionId: input.selectionInteractionId, targetService: input.targetService })); }); return router; } diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 4bfff800545..0f4ad7d8b00 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -4,7 +4,6 @@ import { agents, companies, connectionGrants, issueThreadInteractions, toolConne import { and, eq, or } from "drizzle-orm"; import { APP_STORE_DEFINITIONS, - isRemoteMcpConnectorId, isMemoryConnectorId, GITHUB_CONNECTOR_PROFILES, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, @@ -816,7 +815,7 @@ function connectorEnrollmentPrincipal(req: Request): string { ? await options.paperclipCloudConnector.getCapabilities() : []; const vercelConnect = vercelConnectIntegrationStatus(); - const { enableMcpAggregators, enableMemoryConnectors } = await instanceSettingsService(db).getExperimental(); + const { enableMemoryConnectors } = await instanceSettingsService(db).getExperimental(); res.json({ capabilities: await describeConnectionCreateCapabilities(req, companyId), credentialSources: { @@ -832,7 +831,7 @@ function connectorEnrollmentPrincipal(req: Request): string { : "Vercel Connect setup is disabled on this Paperclip instance.", }, }, - apps: APP_STORE_DEFINITIONS.filter((app) => (enableMcpAggregators || !isRemoteMcpConnectorId(app.slug)) && (enableMemoryConnectors || !isMemoryConnectorId(app.slug))).map((app) => + apps: APP_STORE_DEFINITIONS.filter((app) => (enableMemoryConnectors || !isMemoryConnectorId(app.slug))).map((app) => appWithPaperclipCloudConnectorAvailability(app, advertisedProfiles) ), }); diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index 3fe701bd4c0..66137faaebf 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -1,7 +1,7 @@ import { logActivity } from "./activity-log.js"; import { aiConnectionService } from "./ai-connections.js"; import { aiConnectionBindingSchema } from "@paperclipai/shared"; -import { and, eq } from "drizzle-orm"; +import { and, eq, desc, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, @@ -10,10 +10,14 @@ import { companyMemberships, heartbeatRuns, issueThreadInteractions, + issueComments, issues, } from "@paperclipai/db"; import { APP_STORE_DEFINITIONS, + AGGREGATOR_PRIORITY, AGGREGATOR_NAMES, AGGREGATOR_CATALOG_SOURCES, + findAggregatorService, explicitAggregatorQuery, normalizeConnectionQuery, parseAggregatorRoute, aggregatorProviderQuestion, + aggregatorContinuationInstruction, isRemoteMcpConnectorId, askUserQuestionsPayloadSchema, askUserQuestionsResultSchema, CONNECTABLE_APP_DEFINITIONS, connectionIntentPayloadSchema, getAvailableConnectionMethods, @@ -336,8 +340,8 @@ export function connectionIntentService(db: Db) { }; } - async function search(claims: ConnectionRunClaims, query: string): Promise { - const { run, agent } = await loadRunContext(claims); + async function search(claims: ConnectionRunClaims, query: string, options: { retryProviderChoice?: boolean } = {}): Promise { + const { run, agent, issue } = await loadRunContext(claims); const normalized = query.trim().toLocaleLowerCase(); const tokens = normalized.split(/[^\p{L}\p{N}]+/u).filter(Boolean); const inventory = await connectionInventory(run.companyId); @@ -381,15 +385,181 @@ export function connectionIntentService(db: Db) { connectionId: ready?.id ?? null, }}); } - return { version: 1, query, results: candidates.sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name)).slice(0, 20).map(({ item }) => item) }; + const explicit = explicitAggregatorQuery(query); + const serviceQuery = explicit?.serviceQuery ?? query; + const publicService = findAggregatorService(serviceQuery); + const exact = candidates.filter(({ item }) => normalizeConnectionQuery(item.service) === normalizeConnectionQuery(query) + || normalizeConnectionQuery(item.name) === normalizeConnectionQuery(query) + || item.service === publicService?.slug); + const targetService = publicService?.slug ?? normalizeConnectionQuery(serviceQuery).replaceAll(" ", "-"); + // Indexed-only app labels must be identical when requests re-search the slug. + const targetName = publicService?.name ?? targetService.split("-").map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" "); + const previous = await providerSelections(run.companyId, issue.id, agent.id, run.responsibleUserId!, `connection-provider:${targetService}`); + const explicitConsent = explicit && await hasExplicitProviderRequest(run.companyId, issue.id, run.responsibleUserId!, targetService, explicit.provider, previous[0]); + if (exact.length && (!explicitConsent || exact.some(({ item }) => item.state === "unavailable"))) return directSearchResult(query, exact.map(({ item }) => item)); + const alternatives: ConnectionSearchResultItem[] = []; + if (/^[a-z0-9][a-z0-9-]{0,79}$/.test(targetService)) { + for (const provider of AGGREGATOR_PRIORITY) { + // Broad execute/search descriptions are not evidence of app support. Only a + // namespaced action (or an explicitly listed Executor integration) qualifies. + const connections = inventory.connections.filter(connection => connection.status !== "archived" && connection.enabled && sourceSlugForConnection(connection, inventory.applicationsById) === provider); + let indexedAt: Date | undefined; + for (const connection of connections) { + const { grants } = await access.listConnectionGrants(connection.id, run.companyId); + if (!grants.some(grant => grant.status === "active" && (grant.kind === "organization" + || grant.kind === "user" && grant.subjectUserId === run.responsibleUserId + || grant.kind === "agent" && grant.subjectAgentId === agent.id))) continue; + const entries = await indexedCatalog(connection.id, run.companyId); + const matching = entries.filter(entry => { + const name = entry.toolName.toLowerCase(); + return name.startsWith(targetService + "_") || name.startsWith(targetService + ".") || name.startsWith(targetService + ":") + || provider === "executor" && entry.toolName === "execute" + && (entry.description ?? "").split("\n").some(line => line.trim() === "- `" + targetService + "`"); + }); + for (const entry of matching) if (!indexedAt || entry.lastSeenAt > indexedAt) indexedAt = entry.lastSeenAt; + } + const publishedAt = publicService ? (publicService.providers as Partial>)[provider] : undefined; + const published = Boolean(publishedAt); + if (!published && !indexedAt) continue; + if (await administrativeDenial(run.companyId, agent.id, provider, inventory)) continue; + const app = await resolveService(provider, run.companyId, run.responsibleUserId!, agent.id); + if (!app.available || !app.methods.length) continue; + const ready = await usableConnectionForAgent({ companyId: run.companyId, agentId: agent.id, responsibleUserId: run.responsibleUserId!, serviceSlug: provider, inventory }); + alternatives.push({ + service: `via:${provider}:${targetService}`, name: `${targetName} through ${app.name}`, + source: "aggregator", state: "available", description: `Connect ${targetName} through ${app.name}, an external service.`, + logoUrl: app.branding.logoUrl ?? null, methods: app.methods, connectionId: ready?.id ?? null, + reason: ready ? `${app.name} is connected; verify ${targetName} authorization and the requested action.` + : provider === "arcade" ? "Set up an Arcade gateway with this app's tools, then authorize the app." + : `Connect ${app.name}, then verify and authorize ${targetName}.`, + aggregator: { provider, targetService, targetName, evidenceUrl: published ? AGGREGATOR_CATALOG_SOURCES[provider] ?? null : null, + verifiedAt: publishedAt ?? indexedAt!.toISOString(), + readiness: ready ? "requires_app_verification" : "requires_provider_setup" }, + }); + } + } + if (explicit && explicitConsent) { + const selected = alternatives.find(item => item.aggregator?.provider === explicit.provider); + if (!selected) return { version: 1, query, results: [], instruction: "The explicitly requested external provider is unavailable or its app support could not be verified. Explain the limitation. Do not switch providers automatically." }; + return { version: 1, query, results: [{ ...selected, service: explicit.provider }], + instruction: `The user explicitly requested ${AGGREGATOR_NAMES[explicit.provider]}. Disclose that this external service handles the connection and requests to ${targetName}. Do not ask another provider-choice question or substitute another provider. Call connection_request with service ${explicit.provider} and targetService ${targetService}. Follow its returned instruction; app authorization is not yet verified.` }; + } + if (!alternatives.length) return directSearchResult(query, candidates.filter(({ item, score }) => !isRemoteMcpConnectorId(item.service) && score >= tokens.length) + .sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name)).slice(0, 20).map(({ item }) => item), true); + const question = aggregatorProviderQuestion(targetService, targetName, alternatives); + const latest = previous[0]; + if (latest && (latest.status === "pending" || !options.retryProviderChoice)) { + if (latest.status === "pending") return { version: 1, query, results: alternatives, instruction: "The provider-choice question is already pending. Finish independent work, then yield. Do not ask again.", selectionInteractionId: latest.id }; + const choice = selectedProvider(latest, question); + if (choice === "none" || latest.status === "cancelled") return { version: 1, query, results: [], instruction: "The user declined external providers for this service. Do not connect or ask again. Explain that access remains unavailable. Only if the user explicitly asks to reconsider, search again with retryProviderChoice true." }; + const selected = alternatives.find(item => item.service === choice); + if (selected) return { version: 1, query, results: [selected], selectionInteractionId: latest.id, + instruction: `The user selected ${selected.name}. Call connection_request with service ${selected.service} and selectionInteractionId ${latest.id}. Follow its returned instruction; underlying app access is not yet verified.` }; + if (choice) return { version: 1, query, results: [], instruction: "The chosen external provider is no longer available for this service. Explain the restriction or missing support. Do not switch providers automatically. The user may explicitly request a new provider choice." }; + } + // An agent's query is not proof of human choice. Keep its named provider as + // the sole confirmation option unless a saved user message proves consent. + const offered = explicit ? alternatives.filter(item => item.aggregator?.provider === explicit.provider) : alternatives; + if (!offered.length) return { version: 1, query, results: [], instruction: "The requested external provider is unavailable. Do not switch providers automatically." }; + return { version: 1, query, results: offered, providerQuestion: aggregatorProviderQuestion(targetService, targetName, offered), + instruction: "No matching built-in Paperclip connection was found. Ask the responsible user with providerQuestion exactly as returned (including its id, full prompt, and options). With native request_human_input, use interactionKind questions, continuationPolicy wake_assignee, and payload {version:1, questions:[providerQuestion]}; do not add questionSet. Otherwise use ask_user_questions with the same questions payload. These are external services. Wait for the saved answer; then call connection_request with the selected service identifier and selectionInteractionId set to the answered question interaction ID. None for now means do not connect. Do not claim app access yet." }; + } + + function directSearchResult(query: string, results: ConnectionSearchResultItem[], suggestions = false): ConnectionsSearchResult { + return { version: 1, query, results, instruction: !results.length + ? "No verified connection route was found. Explain that support could not be verified; do not invent a provider route or request unsupported services." + : !suggestions && isRemoteMcpConnectorId(results[0]!.service) && results[0]!.state !== "unavailable" + ? `${results[0]!.name} is an external service. When the user explicitly names this provider, disclose that it handles the connection and requests to the requested app; no additional provider-choice question is necessary. ${results[0]!.state === "ready" ? aggregatorContinuationInstruction(results[0]!.service, "The requested app") : "Call connection_request with the returned service identifier and follow its instruction. Provider setup does not yet verify underlying app access."}` + : suggestions ? "These are possible Paperclip matches, not an exact service match. Clarify the service if ambiguous, then search its exact name. Do not treat unrelated matches as support." + : results[0]!.state === "ready" ? "Use the installed connection. Do not create another connection request." + : results[0]!.state === "unavailable" ? "This connection is unavailable or administratively restricted. Explain the reason. Do not bypass it using another provider." + : "Call connection_request with the returned service identifier. The user already asked to connect: do not ask a generic confirmation or imitate the setup card. Follow the returned instruction." }; + } + + async function providerSelections(companyId: string, issueId: string, agentId: string, userId: string, questionId: string) { + const rows = await db.select().from(issueThreadInteractions).where(and( + eq(issueThreadInteractions.companyId, companyId), eq(issueThreadInteractions.issueId, issueId), + eq(issueThreadInteractions.kind, "ask_user_questions"), eq(issueThreadInteractions.createdByAgentId, agentId), + )).orderBy(desc(issueThreadInteractions.createdAt)); + return rows.filter(row => (!row.addresseeUserId || row.addresseeUserId === userId) + && (!row.resolvedByUserId || row.resolvedByUserId === userId) + && askUserQuestionsPayloadSchema.safeParse(row.payload).success + && (row.payload as { questions: Array<{ id: string }> }).questions.some(question => question.id === questionId)); + } + + async function hasExplicitProviderRequest(companyId: string, issueId: string, userId: string, target: string, provider: string, latest?: typeof issueThreadInteractions.$inferSelect) { + if (latest?.status === "pending") return false; + // Use a persisted human-authored message, never the agent-supplied query or + // mutable task description. Conservative parsing falls back to confirmation. + const [message] = await db.select().from(issueComments).where(and( + eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId), + eq(issueComments.authorUserId, userId), isNull(issueComments.authorAgentId), + isNull(issueComments.createdByRunId), isNull(issueComments.derivedAuthorAgentId), isNull(issueComments.deletedAt), + )).orderBy(desc(issueComments.createdAt), desc(issueComments.id)).limit(1); + if (!message || !/^(?:please\s+)?(?:connect|use)\b/i.test(message.body.trim())) return false; + if (latest && message.createdAt <= (latest.resolvedAt ?? latest.createdAt)) return false; + const request = explicitAggregatorQuery(message.body); + if (request?.provider !== provider) return false; + const known = findAggregatorService(target); + const requested = normalizeConnectionQuery(request.serviceQuery).replace(/^(?:please )?(?:connect|use) (?:to )?/, ""); + const names = known ? [known.slug, known.name, ...known.aliases] : [target]; + return names.some(name => requested === normalizeConnectionQuery(name)); + } + + function selectedProvider(row: typeof issueThreadInteractions.$inferSelect, expected: ReturnType) { + if (row.status !== "answered" || !row.resolvedByUserId || row.resolvedByAgentId) return null; + const payload = askUserQuestionsPayloadSchema.safeParse(row.payload); + const result = askUserQuestionsResultSchema.safeParse(row.result); + if (!payload.success || !result.success) return null; + const question = payload.data.questions.find(q => q.id === expected.id); + const answer = result.data.answers.find(answer => answer.questionId === expected.id); + if (!question || question.selectionMode !== "single" || !answer || answer.optionIds.length !== 1 || answer.otherText) return null; + const selected = answer.optionIds[0]!; + const option = question.options.find(option => option.id === selected); + // A decline survives copy/catalog revisions; it can never authorize a call. + if (selected === "none" && option?.label === "None for now") return selected; + if (question.prompt !== expected.prompt || question.helpText !== expected.helpText) return null; + const route = parseAggregatorRoute(selected); + if (!route || expected.id !== `connection-provider:${route.targetService}`) return null; + const providerName = AGGREGATOR_NAMES[route.provider]; + if (option?.label !== providerName && option?.label !== `${providerName} — Recommended`) return null; + // Eligibility is checked separately. A changed recommendation order must not + // erase the human's choice or silently replace it with another provider. + return selected; } async function request( claims: ConnectionRunClaims, serviceSlug: string, - options: { purpose?: "ai" } = {}, + options: { purpose?: "ai"; selectionInteractionId?: string; targetService?: string } = {}, ): Promise { const context = await loadRunContext(claims); + const route = parseAggregatorRoute(serviceSlug); + let upstreamService: { slug: string; name: string; selectionInteractionId?: string } | undefined; + if (options.targetService) { + if (route || options.purpose || !isRemoteMcpConnectorId(serviceSlug)) throw unprocessable("An explicit target app requires a direct external-provider request"); + const found = await search(claims, `${options.targetService} through ${serviceSlug}`); + const selected = found.results.find(item => item.aggregator?.provider === serviceSlug && (item.service === serviceSlug || Boolean(found.selectionInteractionId && !found.providerQuestion))); + if (!selected?.aggregator) throw forbidden("The requested provider cannot connect this app without verified support and a recorded user choice or explicit user request"); + // Reuse the saved-answer validation below; a pending question also carries + // an interaction ID, but is never permission to create the setup card. + if (selected.service !== serviceSlug) return request(claims, selected.service, { selectionInteractionId: found.selectionInteractionId }); + upstreamService = { slug: selected.aggregator.targetService, name: selected.aggregator.targetName }; + } + if (route) { + if (options.purpose) throw unprocessable("Aggregator routes are tool connections only"); + const found = await search(claims, route.targetService); + const selected = found.results.find(item => item.service === serviceSlug); + if (!selected?.aggregator || !options.selectionInteractionId || found.selectionInteractionId !== options.selectionInteractionId) { + throw forbidden("Choose an external provider using the returned provider question before requesting this connection"); + } + const rows = await providerSelections(context.run.companyId, context.issue.id, context.agent.id, context.run.responsibleUserId!, `connection-provider:${route.targetService}`); + const row = rows.find(row => row.id === options.selectionInteractionId); + if (!row || row.status !== "answered" || row.resolvedByUserId !== context.run.responsibleUserId || row.resolvedByAgentId + || found.results.length !== 1 || found.providerQuestion) throw forbidden("The provider choice is not a valid saved user answer"); + upstreamService = { slug: route.targetService, name: selected.aggregator.targetName, selectionInteractionId: options.selectionInteractionId }; + serviceSlug = route.provider; + } const app = await resolveService(serviceSlug, context.run.companyId, context.run.responsibleUserId!, context.agent.id, options.purpose); if (!app.available || app.methods.length === 0) { throw unprocessable(`Connection service ${serviceSlug} is not available`); @@ -408,7 +578,7 @@ export function connectionIntentService(db: Db) { state: "ready", connectionId: ready.id, interactionId: null, - instruction: options.purpose === "ai" ? `${app.name} authentication is available for the next execution.` : `${app.name} is connected. Use its installed tools; a native continuation will refresh tools if needed.`, + instruction: isRemoteMcpConnectorId(app.slug) ? aggregatorContinuationInstruction(app.slug, upstreamService?.name ?? "The requested app") : options.purpose === "ai" ? `${app.name} authentication is available for the next execution.` : `${app.name} is connected. Use its installed tools; a native continuation will refresh tools if needed.`, }; } if (options.purpose !== "ai" && await administrativeDenial(context.run.companyId, context.agent.id, app.slug, await connectionInventory(context.run.companyId))) { @@ -428,7 +598,8 @@ export function connectionIntentService(db: Db) { version: 1, serviceSlug: app.slug, ...(options.purpose ? { purpose: options.purpose } : {}), - serviceName: app.name, + serviceName: upstreamService ? `${upstreamService.name} through ${app.name}` : app.name, + ...(upstreamService ? { upstreamService } : {}), serviceLogoUrl: app.branding.logoUrl ?? null, serviceDarkLogoUrl: app.branding.darkLogoUrl ?? null, requestingAgentId: context.agent.id, @@ -438,7 +609,7 @@ export function connectionIntentService(db: Db) { sourceRunId: context.run.id, sourceIdentityContextId: context.run.activeIdentityContextId, addresseeUserId: context.run.responsibleUserId!, - idempotencyKey: `connection-intent:${context.run.id}:${context.run.responsibleUserId}:${app.slug}${options.purpose ? ":ai" : ""}`, + idempotencyKey: `connection-intent:${context.run.id}:${context.run.responsibleUserId}:${app.slug}${upstreamService ? `:${upstreamService.slug}` : ""}${options.purpose ? ":ai" : ""}`, }, ); if (interaction.status !== "pending") throw conflict("This connection request has already been resolved. Follow its recorded outcome."); @@ -678,7 +849,9 @@ export function connectionIntentService(db: Db) { return txInteractions.resolveConnectionIntent( loaded.issue, interactionId, - { version: 1, outcome: "connected", connectionId: selectedConnection.id }, + { version: 1, outcome: "connected", connectionId: selectedConnection.id, + ...(isRemoteMcpConnectorId(payload.serviceSlug) ? { instruction: aggregatorContinuationInstruction(payload.serviceSlug, payload.upstreamService?.name ?? "The requested app") } : {}), + }, { userId }, ); }); diff --git a/server/src/services/connection-tool-definitions.ts b/server/src/services/connection-tool-definitions.ts index 65412e5e36c..51f1cf71288 100644 --- a/server/src/services/connection-tool-definitions.ts +++ b/server/src/services/connection-tool-definitions.ts @@ -6,7 +6,7 @@ export const RUNTIME_CONNECTION_TOOL_DEFINITIONS = [ description: CONNECTIONS_SEARCH_TOOL_DESCRIPTION, inputSchema: { type: "object", - properties: { query: { type: "string" } }, + properties: { query: { type: "string" }, retryProviderChoice: { type: "boolean", description: "Only when the user explicitly asks to reconsider a previous provider choice or decline" } }, additionalProperties: false, }, }, @@ -15,7 +15,7 @@ export const RUNTIME_CONNECTION_TOOL_DEFINITIONS = [ description: CONNECTION_REQUEST_TOOL_DESCRIPTION, inputSchema: { type: "object", - properties: { service: { type: "string" } }, + properties: { service: { type: "string" }, targetService: { type: "string", description: "App slug returned by search only when the user explicitly named this external provider" }, selectionInteractionId: { type: "string", description: "Saved answered provider-choice interaction ID for aggregator routes" } }, required: ["service"], additionalProperties: false, }, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 26761267215..fa88c0e256a 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -233,8 +233,8 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta // Apps graduated from Experimental. Ignore historical off values while // continuing to accept the compatibility key in stored settings. enableApps: true, + enableMcpAggregators: true, enableChatConnectors: parsed.data.enableChatConnectors ?? false, - enableMcpAggregators: parsed.data.enableMcpAggregators ?? false, enableMemoryConnectors: parsed.data.enableMemoryConnectors ?? false, enablePipelines: parsed.data.enablePipelines ?? false, enableCases: parsed.data.enableCases ?? false, @@ -276,8 +276,8 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableStreamlinedLeftNavigation: true, enableStreamlinedUi: true, enableApps: true, + enableMcpAggregators: true, enableChatConnectors: false, - enableMcpAggregators: false, enableMemoryConnectors: false, enablePipelines: false, enableCases: false, @@ -334,9 +334,9 @@ export function applyManagedExperimentalOverlay( for (const [key, value] of Object.entries(managedConfig.features) as Array< [ManagedExperimentalFeatureKey, boolean] >) { - // Existing Cloud stack configs may still carry enableApps. Accept the - // document during rollout, but never let the retired flag disable Apps. - if (key === "enableApps") continue; + // Existing Cloud stack configs may still carry retired flags. Accept the + // document during rollout, but never let retired flags disable Apps or MCP aggregators. + if (key === "enableApps" || key === "enableMcpAggregators") continue; next[key] = value; managedKeys[key] = { managed: true, managedBy: PAPERCLIP_CLOUD_MANAGED_BY }; } diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index 2e3b70c4b68..5fca1160f95 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -259,8 +259,12 @@ export class PaperclipRunnerToolAuthority { run_id: this.binding.runId, responsible_user_id: run.responsibleUserId, }; const connections = connectionIntentService(this.db); - if (call.tool === "connections_search") return connections.search(claims, connectionsSearchInputSchema.parse(call.arguments).query); - const result = await connections.request(claims, connectionRequestInputSchema.parse(call.arguments).service); + if (call.tool === "connections_search") { + const input = connectionsSearchInputSchema.parse(call.arguments); + return connections.search(claims, input.query, { retryProviderChoice: input.retryProviderChoice }); + } + const input = connectionRequestInputSchema.parse(call.arguments); + const result = await connections.request(claims, input.service, { selectionInteractionId: input.selectionInteractionId, targetService: input.targetService }); if (result.state === "ready" && this.binding.pinnedMcpDigest && this.binding.enqueueWakeup) { const current = await resolveNativeRuntimeMcpSnapshot({ db: this.db, agent: { id: this.binding.agentId, companyId: this.binding.companyId }, runId: this.binding.runId }); if (current.digest !== this.binding.pinnedMcpDigest) { diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index f2f2f062b67..50962c9ba57 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -11869,17 +11869,11 @@ export function toolAccessService( return `${base.slice(0, 151).trimEnd()} (${randomUUID().slice(0, 6)})`; } - async function assertExperimentalConnectorSetupEnabled(provider: unknown, method: unknown, existing = false) { + async function assertExperimentalConnectorSetupEnabled(provider: unknown, existing = false) { if (!existing && isMemoryConnectorId(provider) && !(await instanceSettingsService(db).getExperimental()).enableMemoryConnectors) { throw forbidden("Enable memory connectors in Settings → Experimental to set up this connection", { code: "memory_connectors_disabled" }); } - 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( @@ -12030,7 +12024,7 @@ export function toolAccessService( ? connectionMethodFor(galleryEntry, inferredMethodKey) : null; const remoteMcpConnector = isRemoteMcpConnectorMethod(galleryEntry?.slug, method?.key); - await assertExperimentalConnectorSetupEnabled(galleryEntry?.slug, method?.key, Boolean( + await assertExperimentalConnectorSetupEnabled(galleryEntry?.slug, Boolean( input.reconnectConnectionId && requestedResumeConnection?.status !== "draft" && requestedResumeConnection?.config.sourceTemplateKey === galleryEntry?.slug, )); @@ -13960,7 +13954,7 @@ export function toolAccessService( ): Promise { const connection = await getConnectionRow(connectionId, companyId); assertSupportedConnection(connection); - await assertExperimentalConnectorSetupEnabled(connection.config.sourceTemplateKey, connection.config.connectionMethodKey, connection.status !== "draft"); + await assertExperimentalConnectorSetupEnabled(connection.config.sourceTemplateKey, connection.status !== "draft"); if (connection.status === "archived") throw conflict("Archived app connections cannot be reconnected"); if (connection.credentialSource === "vercel_connect") { @@ -14139,7 +14133,7 @@ export function toolAccessService( ): Promise { let connection = await getConnectionRow(connectionId, companyId); assertSupportedConnection(connection); - await assertExperimentalConnectorSetupEnabled(connection.config.sourceTemplateKey, connection.config.connectionMethodKey, connection.status !== "draft"); + await assertExperimentalConnectorSetupEnabled(connection.config.sourceTemplateKey, connection.status !== "draft"); if (connection.status === "archived") throw conflict("Archived app connections cannot start sign in"); const sourceTemplateKey = @@ -16636,7 +16630,7 @@ export function toolAccessService( if (!app || app.availability?.available === false) throw notFound("App not found"); const method = connectionMethodFor(app, methodKey); - await assertExperimentalConnectorSetupEnabled(app.slug, method.key); + await assertExperimentalConnectorSetupEnabled(app.slug); if (method.transport !== "mcp_remote" || !method.defaults?.serverUrl) { throw unprocessable( "This app method does not use a hosted remote MCP endpoint", diff --git a/tests/fixtures/connection-review-provider.ts b/tests/fixtures/connection-review-provider.ts index 9ad50744e7d..fc9435380a2 100644 --- a/tests/fixtures/connection-review-provider.ts +++ b/tests/fixtures/connection-review-provider.ts @@ -4,6 +4,7 @@ import { listenOnFetchAllowedPort } from "../e2e/fetch-allowed-port.js"; export async function startReviewProvider( successText = "Pages: Roadmap, Meeting notes", bearerToken?: string, + tool?: { name: string; title: string; description: string }, ) { const captures: Array<{ method: string; toolName: string | null; authorized?: boolean }> = []; const server: Server = createServer(async (req, res) => { @@ -36,10 +37,10 @@ export async function startReviewProvider( result: { tools: [ { - name: "notion:list_pages", - title: "List fixture pages", - description: - "Reads deterministic pages from the fake Notion provider.", + name: tool?.name ?? "notion:list_pages", + title: tool?.title ?? "List fixture pages", + description: tool?.description ?? "Reads deterministic pages from the fake Notion provider.", + annotations: { readOnlyHint: true }, inputSchema: { type: "object", properties: { query: { type: "string" } }, diff --git a/tests/runner-e2e/EVERYDAY-WORKFLOWS.md b/tests/runner-e2e/EVERYDAY-WORKFLOWS.md index 2f40ac3f4e9..938647cabe3 100644 --- a/tests/runner-e2e/EVERYDAY-WORKFLOWS.md +++ b/tests/runner-e2e/EVERYDAY-WORKFLOWS.md @@ -196,3 +196,30 @@ remote**. The test opens the created skill from its task-feed card, checks the canonical skill identity in Studio, saves an edit, and returns to the same skill in the task sidebar. A model's authored document heading is not used as the identity check. + +## External-provider fallback + +Three explicit local cases cover aggregator routing with the normal production +agent guidance. They add nine local cells; the suite now has 47 cells total. + +- `provider-native`: Jira is supported natively and by aggregators. Require the + Jira connection card directly, decline it in the browser, and verify no provider + question, connection creation, or repeated request. +- `provider-decline`: HubSpot has no built-in connector in this fixture. Require + Composio, Arcade, Zapier, and None in that order with external-service disclosure. + Restart the controller, reload the question, choose None through the UI, and + verify one saved answer, no connection changes, and no fabricated result. +- `provider-second`: Install a deterministic Arcade gateway with a read-only + HubSpot action through public APIs. Choose Arcade in the browser after restart. + Require no calls before selection, exactly one call afterwards, the independently + generated contact marker in the agent response, and no duplicate connection. + +These use the existing native profiles and 12-minute local attempt deadline; +expected provider runs are two per case. There are no real third-party mutations. +The fixture server is closed and the harness cleans its disposable instance. +Provider-choice screenshots, persisted interactions, gateway call counts, source +revision, harness digest, and existing usage/cost evidence accompany each attempt. +`connection-routing-evidence.test.ts` calibrates the grader against undisclosed +routing, incorrect ordering, early calls, duplicate questions, and fabricated reads. +Run a single `everyday-workflows.runner-codex-mini.local.provider-decline` cell +first; do not treat these fixtures as live provider compatibility tests. diff --git a/tests/runner-e2e/aggregator-fixture.ts b/tests/runner-e2e/aggregator-fixture.ts new file mode 100644 index 00000000000..bb426d00e0d --- /dev/null +++ b/tests/runner-e2e/aggregator-fixture.ts @@ -0,0 +1,65 @@ +import type { RunnerApi } from "./api.js"; +import { startReviewProvider } from "../fixtures/connection-review-provider.js"; + +/** Deterministic Arcade gateway; the real Paperclip transport, grants and agent are exercised. */ +export async function setupAggregatorFixture( + api: RunnerApi, + companyId: string, + agentId: string, + marker: string, +) { + const provider = await startReviewProvider( + `Contacts: Ada Fixture. Verification code: ${marker}`, + undefined, + { + name: "Hubspot_ListContacts", + title: "List HubSpot contacts", + description: "Read recent HubSpot contacts from this authorized gateway.", + }, + ); + try { + const connected = await api.post( + `/api/companies/${companyId}/tools/apps/connect`, + { + name: "Arcade fixture", + link: provider.url, + authMode: "none", + grantKind: "organization", + }, + ); + // Import a reachable fixture MCP endpoint through the normal generic path, + // then tag its provider via the public configuration API. Branded Arcade + // setup correctly rejects localhost as an official Arcade gateway URL. + const connection = await api.get( + `/api/tool-connections/${connected.connectionId}`, + ); + await api.patch(`/api/tool-connections/${connected.connectionId}`, { + config: { ...connection.config, sourceTemplateKey: "arcade" }, + }); + await api.post( + `/api/companies/${companyId}/tools/apps/${connected.connectionId}/finish`, + { + enabledCatalogEntryIds: [ + ...connected.actions.readOnly, + ...connected.actions.canMakeChanges, + ].map((action: any) => action.catalogEntryId), + askFirstCatalogEntryIds: [], + access: { agentIds: [agentId] }, + }, + ); + const installed = await api.request.put( + `/api/tool-connections/${connected.connectionId}/installs`, + { data: { installs: [{ targetType: "agent", targetId: agentId }] } }, + ); + if (!installed.ok()) + throw new Error("Could not install the aggregator fixture"); + return { + ...provider, + invocationCount: () => + provider.captures.filter((call) => call.method === "tools/call").length, + }; + } catch (error) { + await provider.close(); + throw error; + } +} diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index 1198cb045f9..67ab3836244 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -71,10 +71,10 @@ describe("runner E2E catalog", () => { expect(localIntegrityTasks).toHaveLength(2); expect(openRouterBreadthTasks).toHaveLength(3); expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([ - 16, 16, 23, 38, 52, 28, 18, 6, 6, 48, 16, 10, 2, + 16, 16, 23, 47, 52, 28, 18, 6, 6, 48, 16, 10, 2, ]); - expect(validateRunnerCatalog()).toHaveLength(279); - expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(279); + expect(validateRunnerCatalog()).toHaveLength(288); + expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(288); expect( runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"), ).toHaveLength(48); diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index 20709372ae8..a8a5bd7504a 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -966,7 +966,7 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ id: "everyday-workflows", label: "Everyday Paperclip Work", manualOnly: true, description: "Real user requests, useful downloaded work, and durable continuation using production instructions.", groups: ["native"], profiles: everydayProfiles, environments: [localEnvironment, daytonaWarmEnvironment], - tasks: everydayTasks, expectedMatrixSize: 38, + tasks: everydayTasks, expectedMatrixSize: 47, excludedExecutionIds: [...everydayProfiles.flatMap(profile => everydayTasks .filter(task => !["build-revise", "delegate-feedback", "recover-controller", "create-skill-studio"].includes(task.id)) .map(task => `everyday-workflows.${profile.id}.daytona.${task.id}`))], diff --git a/tests/runner-e2e/connection-routing-evidence.test.ts b/tests/runner-e2e/connection-routing-evidence.test.ts new file mode 100644 index 00000000000..0ccc93b7c6d --- /dev/null +++ b/tests/runner-e2e/connection-routing-evidence.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { + gradeProviderChoice, + gradeProviderOutcome, +} from "./connection-routing-evidence.js"; +const pending = [ + { + id: "question", + kind: "ask_user_questions", + status: "pending", + payload: { + questions: [ + { + id: "connection-provider:hubspot", + prompt: + "Connect HubSpot through an external service? These services handle the connection and requests to HubSpot. Connecting a provider does not yet authorize HubSpot.", + helpText: "", + selectionMode: "single", + options: [ + "via:composio:hubspot", + "via:arcade:hubspot", + "via:zapier:hubspot", + "none", + ].map((id) => ({ id })), + }, + ], + }, + }, +]; +describe("external-provider evidence oracle", () => { + it("requires disclosure, exact ordering, None, and no early calls", () => { + expect(gradeProviderChoice(pending, 0).interaction.id).toBe("question"); + expect(() => gradeProviderChoice(pending, 1)).toThrow("before"); + expect(() => gradeProviderChoice([], 0)).toThrow(); + const wrong = structuredClone(pending); + wrong[0]!.payload.questions[0]!.options.reverse(); + expect(() => gradeProviderChoice(wrong, 0)).toThrow("ordering"); + wrong[0]!.payload.questions[0]!.prompt = "Connect HubSpot?"; + wrong[0]!.payload.questions[0]!.helpText = ""; + expect(() => gradeProviderChoice(wrong, 0)).toThrow("disclosure"); + }); + it("rejects fabricated success, duplicate questions, and execution after None", () => { + const input = { + rows: [ + { + id: "question", + kind: "ask_user_questions", + status: "answered", + result: { + answers: [ + { + questionId: "connection-provider:hubspot", + optionIds: ["via:arcade:hubspot"], + }, + ], + }, + }, + ], + decisionId: "question", + selected: "via:arcade:hubspot", + calls: 1, + response: "Ada, MARKER", + marker: "MARKER", + sameConnections: true, + }; + expect(gradeProviderOutcome(input).every((check) => check.passed)).toBe( + true, + ); + expect( + gradeProviderOutcome({ ...input, calls: 0 }).every( + (check) => check.passed, + ), + ).toBe(false); + expect( + gradeProviderOutcome({ + ...input, + rows: [...input.rows, ...input.rows], + }).every((check) => check.passed), + ).toBe(false); + input.selected = "none"; + input.rows[0]!.result.answers[0]!.optionIds = ["none"]; + expect(gradeProviderOutcome(input).every((check) => check.passed)).toBe( + false, + ); + expect( + gradeProviderOutcome({ + ...input, + calls: 0, + response: "Could not retrieve contacts", + }).every((check) => check.passed), + ).toBe(true); + }); +}); diff --git a/tests/runner-e2e/connection-routing-evidence.ts b/tests/runner-e2e/connection-routing-evidence.ts new file mode 100644 index 00000000000..d8627407082 --- /dev/null +++ b/tests/runner-e2e/connection-routing-evidence.ts @@ -0,0 +1,74 @@ +/** Independent oracle: a plausible narrative without a persisted decision is not proof. */ +export function gradeProviderChoice( + rows: Array<{ id: string; kind: string; status: string; payload?: any }>, + calls: number, +) { + if (calls !== 0) + throw new Error( + "Provider executed before the user chose an external service", + ); + const pending = rows.filter((row) => row.status === "pending"); + if (pending.length !== 1 || pending[0]?.kind !== "ask_user_questions") + throw new Error("Expected one external-provider question before any setup"); + const question = pending[0].payload?.questions?.find( + (q: any) => q.id === "connection-provider:hubspot", + ); + if ( + !question || + !/external service/i.test(`${question.prompt} ${question.helpText}`) || + !/handle the connection and requests to HubSpot/.test(question.prompt) || + !/does not yet authorize HubSpot/.test(question.prompt) || + question.selectionMode !== "single" + ) + throw new Error("Missing app-specific external-service disclosure"); + const ids = question.options.map((option: any) => option.id); + if ( + JSON.stringify(ids) !== + JSON.stringify([ + "via:composio:hubspot", + "via:arcade:hubspot", + "via:zapier:hubspot", + "none", + ]) + ) + throw new Error("Incorrect verified provider ordering or missing None"); + return { interaction: pending[0], question }; +} + +export function gradeProviderOutcome(input: { + rows: Array<{ id: string; kind: string; status: string; result?: any }>; + decisionId: string; + selected: string; + calls: number; + response: string; + marker: string; + sameConnections: boolean; +}) { + const decision = input.rows.find((row) => row.id === input.decisionId); + const selected = decision?.result?.answers?.find( + (answer: any) => answer.questionId === "connection-provider:hubspot", + )?.optionIds; + return [ + { + id: "provider-choice-durable", + passed: + decision?.status === "answered" && + JSON.stringify(selected) === JSON.stringify([input.selected]), + detail: "The chosen provider or None is saved on this task.", + }, + { + id: "provider-no-extra-setup", + passed: input.rows.length === 1 && input.sameConnections, + detail: "No replacement question or unnecessary connection was created.", + }, + { + id: "provider-use-matches-choice", + passed: + input.selected === "none" + ? input.calls === 0 && !input.response.includes(input.marker) + : input.calls === 1 && input.response.includes(input.marker), + detail: + "None prevents execution; choosing Arcade returns its independently observed marker exactly once.", + }, + ]; +} diff --git a/tests/runner-e2e/everyday-cases.ts b/tests/runner-e2e/everyday-cases.ts index 11afe750353..cbc709a073e 100644 --- a/tests/runner-e2e/everyday-cases.ts +++ b/tests/runner-e2e/everyday-cases.ts @@ -32,6 +32,9 @@ export const SLUGIFY_REVISION = `Add an optional --separator argument that accep export const LATE_REQUIREMENT = `Also support --max-length as a positive integer. Truncate the final slug to that length, then remove any trailing separator. In particular, input " Queue--Ready!! " with --max-length 7 must print "queue-r". Preserve the default behavior.`; const definitions = [ + ["provider-decline", "Decline external providers", "Connect HubSpot so you can read my recent contacts. If I choose None for now, explain that you could not retrieve the contacts and finish without connecting.", 2], + ["provider-second", "Choose and reuse the second external provider", "Connect HubSpot and read my recent contacts. Report the contact names and verification code returned by the service.", 2], + ["provider-native", "Prefer a built-in connection over external providers", "Connect Jira so you can read my recent issues. If I choose Not now, explain that you could not retrieve the issues and finish without connecting.", 2], [ "build-revise", "Build, download, and revise a project", diff --git a/tests/runner-e2e/everyday-flow.ts b/tests/runner-e2e/everyday-flow.ts index 428f1d761b6..635bcd96a4c 100644 --- a/tests/runner-e2e/everyday-flow.ts +++ b/tests/runner-e2e/everyday-flow.ts @@ -14,6 +14,8 @@ import type { MatrixExecution } from "./types.js"; import { createTaskThroughUi, submitTaskReply } from "./user-actions.js"; import { waitForTaskChatRendered } from "./continuation-screenshot.js"; import { hasPersistedSource, isSavedSourceCheckpoint } from "./everyday-interruption.js"; +import { setupAggregatorFixture } from "./aggregator-fixture.js"; +import { gradeProviderChoice, gradeProviderOutcome } from "./connection-routing-evidence.js"; import { setupConnectionReview } from "./connection-reviews.js"; import { pendingStoryDecision, @@ -156,7 +158,10 @@ export async function runEverydayFlow(input: Input) { let review: Awaited> | undefined; let project = fixtures.project; const caseId = execution.task.id; - const decliningConnection = caseId === "connection-decline"; + const providerChoice = caseId === "provider-decline" || caseId === "provider-second"; + const nativeProviderCase = caseId === "provider-native"; + let aggregatorFixture: Awaited> | undefined; + const decliningConnection = caseId === "connection-decline" || nativeProviderCase; const declining = decliningConnection || caseId === "service-decline"; let decisionId: string | undefined; let decisionResolvedAt: string | undefined; @@ -234,7 +239,11 @@ export async function runEverydayFlow(input: Input) { `/${prefix}/issues/${issue.identifier ?? issue.id}`; async function openTask(issue: StoryIssue) { await page.goto(taskUrl(issue), { waitUntil: "domcontentloaded" }); - await waitForTaskChatRendered(page, String(issue.title)); + if (providerChoice || nativeProviderCase) { + await expect(page.locator('[data-testid="task-chat-thread"], [data-testid="thread-root"]').first()).toBeVisible({timeout:30_000}); + await expect(page.getByRole("heading", {name:String(issue.title),exact:true})).toBeVisible(); + await expect(page.getByTestId("issue-chat-skeleton")).toHaveCount(0); + } else await waitForTaskChatRendered(page, String(issue.title)); } async function openParent() { await openTask(parent!); @@ -538,6 +547,8 @@ export async function runEverydayFlow(input: Input) { "everyday-flow.ts", "everyday-cases.ts", "everyday-decisions.ts", + "aggregator-fixture.ts", + "connection-routing-evidence.ts", "everyday-delivery.ts", "everyday-observations.ts", "everyday-artifact.py", @@ -574,7 +585,7 @@ export async function runEverydayFlow(input: Input) { throw new Error(`Artifact sandbox qualification failed before task creation: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); } } - if (!project && !caseId.startsWith("service-") && !decliningConnection) { + if (!project && !caseId.startsWith("service-") && !decliningConnection && !providerChoice) { project = await api.post( `/api/companies/${fixtures.company.id}/projects`, { @@ -634,6 +645,11 @@ export async function runEverydayFlow(input: Input) { marker: `Pages: Roadmap, Meeting notes. Verification code: SERVICE_${nonce}`, authenticated: true, }); + if (providerChoice || nativeProviderCase) { + if (caseId === "provider-second") aggregatorFixture = await setupAggregatorFixture(api, fixtures.company.id, fixtures.agent.id, `CONTACTS_${nonce}`); + const state = await api.get<{connections:Row[]}>(`/api/companies/${fixtures.company.id}/tools/connections`); + initialConnections = state.connections.map(c=>c.id); + } if (decliningConnection) { const state = await api.get<{ connections: Row[] }>( `/api/companies/${fixtures.company.id}/tools/connections`, @@ -870,6 +886,37 @@ export async function runEverydayFlow(input: Input) { await openParent(); } } + if (providerChoice) { + const rows = await pollUntil({ + label: "external-provider choice", deadlineAt: input.deadlineAt, + load: async () => { + const runs = await api.get(`/api/issues/${parent!.id}/runs`); + const failure = runs.find(run => ["failed", "timed_out"].includes(run.status)); + if (failure) throw new Error(`Stopped waiting for external-provider choice: agent failed before selection: ${failure.error ?? failure.status}`); + const rows = await api.get(`/api/issues/${parent!.id}/interactions`); + const issue = await api.get(`/api/issues/${parent!.id}`); + if (!rows.some(row=>row.status==="pending") && ["done", "blocked", "cancelled"].includes(issue.status)) throw new Error(`Stopped waiting for external-provider choice: task reached ${issue.status} without asking the user`); + return rows; + }, + accept: rows => rows.some(row=>row.status==="pending"), + }); + const decision = gradeProviderChoice(rows as any, aggregatorFixture?.invocationCount() ?? 0); + decisionId = decision.interaction.id; + check("provider-disclosed-before-choice", true, "Ranked external providers and None were offered before any call."); + // Exercise durable selection across a real controller restart and browser reload. + await input.restart(); + await openParent(); + const choice = page.getByRole("radio", {name: caseId === "provider-decline" ? /None for now/ : /^Arcade/}); + await expect(choice).toBeVisible(); + await input.capture("provider-choice", "External service choice after restart", "provider-choice.png"); + await choice.click(); + await page.getByRole("button", {name:"Submit answers",exact:true}).click(); + await pollUntil({label:"provider choice saved", deadlineAt:input.deadlineAt, + load:()=>api.get(`/api/issues/${parent!.id}/interactions`), + accept:rows=>rows.some(row=>row.id===decisionId && row.status==="answered"), + }); + note("provider-choice-submitted", {interactionId:decisionId, selected:caseId === "provider-decline" ? "none" : "via:arcade:hubspot"}); + } if (review || decliningConnection) { const interactions = await pollUntil({ label: "story decision request", @@ -905,7 +952,7 @@ export async function runEverydayFlow(input: Input) { interactions.interactions, review ? { kind: "tool", connectionId: review.connectionId } - : { kind: "connection", serviceSlug: "notion" }, + : { kind: "connection", serviceSlug: nativeProviderCase ? "jira" : "notion" }, ); await expect( page.getByRole("button", { @@ -924,7 +971,7 @@ export async function runEverydayFlow(input: Input) { true, review ? "Tool approval belongs to the installed page service." - : "New connection request is for Notion.", + : `New connection request is for ${nativeProviderCase ? "Jira" : "Notion"}, without an external-provider question.`, ); if (review) check( @@ -976,7 +1023,8 @@ export async function runEverydayFlow(input: Input) { }); } await settled( - caseId === "stop-redirect" ? `Reference ${nonce}` : undefined, + caseId === "stop-redirect" ? `Reference ${nonce}` + : caseId === "provider-second" ? `CONTACTS_${nonce}` : undefined, ); if (caseId === "create-skill-studio") { const createdSkills = await api.get( @@ -1044,6 +1092,15 @@ export async function runEverydayFlow(input: Input) { check("return-content-persisted", true, "Returning to the task shows the saved Skill Studio edit."); } } + if (providerChoice) { + const issue = ev.issues.find(i=>i.id===parent!.id)!; + const state = await api.get<{connections:Row[]}>(`/api/companies/${fixtures.company.id}/tools/connections`); + ev.checks.push(...gradeProviderOutcome({rows:issue.interactions as any, decisionId:decisionId!, + selected:caseId === "provider-decline" ? "none" : "via:arcade:hubspot", calls:aggregatorFixture?.invocationCount() ?? 0, + response: (issue.comments ?? []).filter((c:Row)=>c.authorAgentId).map((c:Row)=>c.body).join("\n"), marker:`CONTACTS_${nonce}`, + sameConnections:isDeepStrictEqual(state.connections.map(c=>c.id).sort(), initialConnections.sort()), + })); + } if (declining) { const issue = ev.issues.find((i) => i.id === parent!.id)!; const requests = issue.interactions as Row[]; @@ -1444,12 +1501,13 @@ export async function runEverydayFlow(input: Input) { ), "No completion confirmation or unanswered interaction remains.", ); - await waitForTaskChatRendered(page, String(parent!.title)); + if (providerChoice || nativeProviderCase) await openParent(); + else await waitForTaskChatRendered(page, String(parent!.title)); const latestAgentComment = ev.issues .find((i) => i.id === parent!.id) ?.comments?.filter((c: Row) => c.authorAgentId) .at(-1); - if (latestAgentComment) { + if (latestAgentComment && !providerChoice && !nativeProviderCase) { const response = page.locator(`[id="comment-${latestAgentComment.id}"]`); await expect(response).toBeVisible(); await response.scrollIntoViewIfNeeded(); @@ -1519,14 +1577,16 @@ export async function runEverydayFlow(input: Input) { ), }); } - await input.evidence("everyday-workflow.json", ev); - await input.evidence("api-state.json", { - capturePhase: "everyday-final", - issue: parent, - runs: ev.runs, - issues: ev.issues, - checks: ev.checks, - }); - await review?.close(); + try { + await input.evidence("everyday-workflow.json", ev); + await input.evidence("api-state.json", { + capturePhase: "everyday-final", issue: parent, runs: ev.runs, + issues: ev.issues, checks: ev.checks, + }); + if (aggregatorFixture) await input.evidence("aggregator-provider-calls.json", { calls: aggregatorFixture.captures }); + } finally { + try { await aggregatorFixture?.close(); } + finally { await review?.close(); } + } } } diff --git a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx index 86ab271129a..91c214c3652 100644 --- a/ui/src/features/connections/ConnectionIntentInteractionBody.tsx +++ b/ui/src/features/connections/ConnectionIntentInteractionBody.tsx @@ -163,6 +163,7 @@ export function ConnectionIntentInteractionBody({ const setupProps: ConnectionSetupFlowProps | null = setupQuery.data ? { host: "dialog", + upstreamServiceName: interaction.payload.upstreamService?.name, serviceSlug: interaction.payload.serviceSlug.startsWith("connection:") ? undefined : interaction.payload.serviceSlug, configuredConnection: interaction.payload.serviceSlug.startsWith("connection:") ? setupQuery.data.existingConnections[0] : undefined, requestedAgentId: setupQuery.data.requestedAgentId, @@ -181,8 +182,8 @@ export function ConnectionIntentInteractionBody({ interaction.status === "accepted" ? { icon: CheckCircle2, - title: `${interaction.payload.serviceName} connected`, - body: isAi ? "This agent can now use the connection." : `${interaction.payload.requestingAgentName} can use this connection on the continuation run.`, + title: interaction.payload.upstreamService ? "External provider connected" : `${interaction.payload.serviceName} connected`, + body: interaction.payload.upstreamService ? `${interaction.payload.requestingAgentName} can now verify and authorize ${interaction.payload.upstreamService.name} through this provider. The app is not yet verified.` : isAi ? "This agent can now use the connection." : `${interaction.payload.requestingAgentName} can use this connection on the continuation run.`, } : interaction.status === "rejected" ? { diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index d1ef67d0329..e1907963871 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -1,6 +1,5 @@ import { RemoteMcpProductionSetup } from "./remote-mcp/RemoteMcpProductionSetup"; import { useMemoryConnectorsEnabled } from "@/hooks/useMemoryConnectorsEnabled"; -import { useMcpAggregatorsEnabled } from "@/hooks/useMcpAggregatorsEnabled"; import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep"; import { ConnectionChoiceList } from "./ConnectionChoiceList"; import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode, type Ref } from "react"; @@ -509,6 +508,7 @@ export function readConnectionIntentOAuthOutcome( } export interface ConnectionSetupFlowProps { + upstreamServiceName?: string; aiConnection?: import("@paperclipai/shared").AiConnectionBinding; /** Provider-specific authentication inside the existing access/setup shell. Undefined retains the standard credential form. */ renderCredentialStep?: (context: { app: AppDefinition; name: string; grantKind: ConnectionGrantKind; agentIds: string[]; allAgents: boolean; onBack: () => void }) => ReactNode; @@ -538,7 +538,6 @@ export function ConnectionSetupFlow(props: ConnectionSetupFlowProps = {}) { const [searchParams] = useSearchParams(); const params = useParams<{ appKey?: string }>(); const { selectedCompanyId } = useCompany(); - const aggregators = useMcpAggregatorsEnabled(); const memory = useMemoryConnectorsEnabled(); const interactionId = props.interactionId || searchParams.get("intent") || undefined; const source = props.serviceSlug || searchParams.get("source") || params.appKey || searchParams.get("appKey"); @@ -561,8 +560,6 @@ export function ConnectionSetupFlow(props: ConnectionSetupFlowProps = {}) { } if (!props.byoOnly && (props.credentialSource ?? "paperclip_vault") === "paperclip_vault" && isRemoteMcpConnectorId(provider) && (!method || isRemoteMcpConnectorMethod(provider, method))) { - if (!aggregators.loaded) return

Loading connection settings…

; - if (!aggregators.enabled) return

Enable MCP aggregators in Settings → Experimental to set up this connection.

; return ; } return ; diff --git a/ui/src/features/connections/remote-mcp/RemoteMcpAccountChoice.tsx b/ui/src/features/connections/remote-mcp/RemoteMcpAccountChoice.tsx new file mode 100644 index 00000000000..c0be6d7367b --- /dev/null +++ b/ui/src/features/connections/remote-mcp/RemoteMcpAccountChoice.tsx @@ -0,0 +1,73 @@ +import { Button } from "@/components/ui/button"; +import { InlineBanner } from "@/components/InlineBanner"; +import { ConnectionChoiceList } from "../ConnectionChoiceList"; + +/** Shared presentation for account reuse, including the requested external app. */ +export function RemoteMcpAccountChoice({ + providerName, + upstreamServiceName, + connections, + pendingId, + error, + onSelect, + onCancel, + onConnectNew, +}: { + providerName: string; + upstreamServiceName?: string; + connections: { id: string; name: string }[]; + pendingId?: string | null; + error?: string | null; + onSelect: (id: string) => void; + onCancel?: () => void; + onConnectNew: () => void; +}) { + return ( +
+
+

+ {upstreamServiceName + ? `Connect ${upstreamServiceName} through ${providerName}` + : `Connect ${providerName}`} +

+

+ Use an existing connection or connect a new account. Existing access + stays unchanged. +

+
+ {upstreamServiceName && ( + + {providerName} is an external service that handles the connection and + requests to {upstreamServiceName}. Reusing this account does not yet + verify app access; the agent will check it and guide any additional + authorization. + + )} + ({ + ...connection, + description: "Provider account available", + }))} + pendingId={pendingId} + onSelect={onSelect} + /> + {error && ( +

+ {error} +

+ )} +
+ + +
+
+ ); +} diff --git a/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx b/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx index 0b465f8afb4..6fca08e095e 100644 --- a/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx +++ b/ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx @@ -25,7 +25,8 @@ function FieldHelp({ label, children }: { label: string; children: ReactNode }) /** Controlled presentation shared by provider setup, configuration imports and review stories. * Authentication, persistence and calls belong to the controller, never these views. */ -export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agents, connectionId, fixedGrantKind, lockedAgentId, host = "page", authorizationUrl }: { +export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agents, connectionId, fixedGrantKind, lockedAgentId, host = "page", authorizationUrl, upstreamServiceName }: { + upstreamServiceName?: string; host?: "page" | "dialog"; lockedAgentId?: string; authorizationUrl?: string; @@ -60,10 +61,11 @@ export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agent return
= 0 && !s.setupComplete ? `Step ${currentStep + 1} of 2` : s.step === "draft" ? `Your ${provider.name} setup is ready to resume.` : s.step === "permissions" ? `Connected${s.identity ? ` as ${s.identity}` : ""} · ${s.tools.length} actions available` : `Manage this ${provider.name} connection.`} step={currentStep >= 0 && !s.setupComplete ? "access" : "gallery"} activeIndex={currentStep} labels={["Access", "Connect"]} onCancel={busy || s.step === "management" || s.step === "permissions" || s.step === "draft" ? undefined : a.saveExit} />
+ {upstreamServiceName && {provider.name} is an external service that handles the connection and requests to {upstreamServiceName}. After connecting, the agent will verify the app and guide you through any additional authorization.} {s.notice &&

{s.notice}

} {s.step === "access" && { if (grantKind !== "agent") change({ grantKind }); }} diff --git a/ui/src/features/connections/remote-mcp/RemoteMcpProductionSetup.tsx b/ui/src/features/connections/remote-mcp/RemoteMcpProductionSetup.tsx index 992f515bfaf..42966566a76 100644 --- a/ui/src/features/connections/remote-mcp/RemoteMcpProductionSetup.tsx +++ b/ui/src/features/connections/remote-mcp/RemoteMcpProductionSetup.tsx @@ -1,8 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { REMOTE_MCP_CONNECTOR_METHODS, type ToolConnection } from "@paperclipai/shared"; -import { Button } from "@/components/ui/button"; -import { ConnectionChoiceList } from "../ConnectionChoiceList"; +import { RemoteMcpAccountChoice } from "./RemoteMcpAccountChoice"; import { readConnectionIntentOAuthOutcome, type ConnectionSetupFlowProps } from "../ConnectionSetupFlow"; import { agentsApi } from "@/api/agents"; import { toolsApi } from "@/api/tools"; @@ -24,7 +23,7 @@ function readAccessDraft(key: string): Partial { } export function RemoteMcpProductionSetup({ providerId, connection, host = "page", interactionId, - requestedAgentId, existingConnections = [], forceNewConnection, onUseExisting, onComplete, onCancel, onPhaseChange, + upstreamServiceName, requestedAgentId, existingConnections = [], forceNewConnection, onUseExisting, onComplete, onCancel, onPhaseChange, }: ConnectionSetupFlowProps & { providerId: RemoteMcpProviderId; connection?: ToolConnection }) { const provider = remoteMcpProviders[providerId]; const { selectedCompanyId } = useCompany(); @@ -180,15 +179,13 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page" resumeDraft: () => edit({ step: "connect" }), finish: () => { if (savedConnection.current) void finish(savedConnection.current.id); }, refresh: () => {}, reconnect: () => edit({ step: "connect" }), disconnect: () => {}, }; - if (showChoices && onUseExisting) return
-

Connect {provider.name}

Use an existing connection or connect a new account. Existing access stays unchanged.

- ({ id: c.id, name: c.name, description: "Ready to use" }))} pendingId={choicePending} onSelect={(id) => { + if (showChoices && onUseExisting) return setShowChoices(false)} onSelect={(id) => { setChoicePending(id); setChoiceError(null); void onUseExisting(id).catch((error) => { setChoiceError(error instanceof Error ? error.message : "Could not use this connection."); setChoicePending(null); }); - }} /> - {choiceError &&

{choiceError}

} -
-
; + }} />; if (connection && !installs.data) return

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

{installs.isError && }
; - return ; + return ; } diff --git a/ui/src/hooks/useMcpAggregatorsEnabled.ts b/ui/src/hooks/useMcpAggregatorsEnabled.ts deleted file mode 100644 index 5cb163f35da..00000000000 --- a/ui/src/hooks/useMcpAggregatorsEnabled.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { instanceSettingsApi } from "@/api/instanceSettings"; -import { queryKeys } from "@/lib/queryKeys"; - -/** Default-off setup gate. Existing connections and their grants remain usable. */ -export function useMcpAggregatorsEnabled() { - const query = useQuery({ - queryKey: queryKeys.instance.experimentalSettings, - queryFn: () => instanceSettingsApi.getExperimental(), - }); - return { enabled: !query.isError && query.data?.enableMcpAggregators === true, loaded: query.isFetched }; -} diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index b8a433c97ce..8e7d7d9654a 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -78,8 +78,8 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { enableStreamlinedLeftNavigation: true, enableStreamlinedUi: true, enableApps: true, + enableMcpAggregators: true, enableChatConnectors: false, - enableMcpAggregators: false, enableMemoryConnectors: false, enablePipelines: false, enableCases: false, @@ -225,17 +225,10 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) } }); - it("defaults MCP aggregators off and persists an explicit toggle in both directions", async () => { + it("does not offer a retired MCP aggregators toggle", async () => { await renderPage(); - const selector = 'button[aria-label="Toggle MCP aggregators experimental setting"]'; - expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe("false"); - expect(container.textContent).toContain("Existing MCP connections keep running."); - for (const enabled of [true, false]) { - await act(() => container.querySelector(selector)!.click()); - await flushReact(); - expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({ enableMcpAggregators: enabled }); - expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe(String(enabled)); - } + expect(container.querySelector('button[aria-label="Toggle MCP aggregators experimental setting"]')).toBeNull(); + expect(container.textContent).not.toContain("MCP aggregators"); }); it("defaults chat connectors off and persists an explicit toggle in both directions", async () => { @@ -1062,7 +1055,7 @@ describe("InstanceExperimentalSettings — operator-hidden cards", () => { it("keeps only permitted controls in alphabetical order, including when hidden features are enabled", async () => { setWorktreeRuntimeMeta(true); - const visible = new Set(["enableExternalObjects", "enableMcpAggregators", "enableSimplifiedEnglishInteractions"]); + const visible = new Set(["enableExternalObjects", "enableMemoryConnectors", "enableSimplifiedEnglishInteractions"]); await renderPage( INSTANCE_FEATURE_KEYS.filter((key) => !visible.has(key)).map((key) => `instance.experimental.${key}`), { @@ -1078,7 +1071,7 @@ describe("InstanceExperimentalSettings — operator-hidden cards", () => { expect([...container.querySelectorAll("h3")].map((heading) => heading.textContent)).toEqual([ "Enable External Objects", - "MCP aggregators", + "Memory connectors", "Simplified English Interactions", ]); expect([...container.querySelectorAll("section h2")].map((heading) => heading.textContent)).toEqual([ diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index a179b09a3b9..8fc3a2c6410 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -432,18 +432,6 @@ export function InstanceExperimentalSettings() { ariaLabel="Toggle first task plan proposal experimental setting" /> - toggleMutation.mutate({ enableMcpAggregators: checked })} - disabled={toggleMutation.isPending} - settingKey="enableMcpAggregators" - managed={managedKeys.enableMcpAggregators} - ariaLabel="Toggle MCP aggregators experimental setting" - /> - { } it.each(["zapier", "arcade", "composio", "executor"])("inline aggregator %s collects the endpoint and completes only for the requester", async (provider) => { - experimentalMock.mockResolvedValue({ enableMcpAggregators: true }); const onComplete = vi.fn(); const popup = vi.spyOn(window, "open").mockReturnValue(null); const connection = { id: "conn-inline", status: "draft", credentialPolicy: "per_user", authKind: "api_key" }; @@ -359,7 +358,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); it.each(["arcade", "composio", "executor"])("inline aggregator %s binds OAuth to the task and retries the same draft", async (provider) => { - experimentalMock.mockResolvedValue({ enableMcpAggregators: true }); const onComplete = vi.fn(); const onPhaseChange = vi.fn(); const popup = { closed: false, location: { assign: vi.fn() }, focus: vi.fn(), close: vi.fn() }; @@ -394,7 +392,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); it("inline aggregator saves and resumes a draft without storing credentials in browser storage", async () => { - experimentalMock.mockResolvedValue({ enableMcpAggregators: true }); const onCancel = vi.fn(); const connection = { id: "conn-inline-draft", status: "draft", credentialPolicy: "per_user", authKind: "none", config: { url: "https://provider.example/mcp", sourceTemplateKey: "zapier", connectionMethodKey: "generated-url" } }; connectAppMock.mockResolvedValue({ connectionId: connection.id, connection, catalog: [] }); @@ -419,7 +416,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); it("inline aggregator reuses an eligible account without changing its access", async () => { - experimentalMock.mockResolvedValue({ enableMcpAggregators: true }); const onUseExisting = vi.fn().mockResolvedValue(undefined); await render(undefined, false, ); await act(async () => buttonContaining("Existing Composio")!.click()); @@ -445,10 +441,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(container.textContent).not.toContain("Enable memory connectors"); }); - it.each(["zapier", "arcade", "composio", "executor"])("blocks direct %s setup while MCP aggregators are off", async (provider) => { + it.each(["zapier", "arcade", "composio", "executor"])("opens direct %s setup with default settings", async (provider) => { mockSearch.value = `source=${provider}`; await render(); - expect(container.textContent).toContain("Enable MCP aggregators"); + expect(container.textContent).not.toContain("Enable MCP aggregators"); + await passAccessStep(); + expect(container.textContent).toContain("MCP server URL"); expect(connectAppMock).not.toHaveBeenCalled(); expect(startOAuthMock).not.toHaveBeenCalled(); }); @@ -456,7 +454,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { it.each(["arcade", "composio", "executor"])("explains a failed %s OAuth return and retries the same saved draft", async (provider) => { const draft = { id: "conn-oauth-draft", companyId: "company-1", status: "draft", authKind: "oauth", credentialPolicy: "shared", config: { sourceTemplateKey: provider, connectionMethodKey: "mcp", url: "https://example.com/mcp" } }; mockSearch.value = `source=${provider}&resume=${draft.id}&oauth=failed&code=oauth_callback_failed&error_description=untrusted-provider-message`; - experimentalMock.mockResolvedValue({ enableMcpAggregators: true }); getConnectionMock.mockResolvedValue(draft); connectAppMock.mockResolvedValue({ connectionId: draft.id, connection: draft, catalog: [], auth: { kind: "oauth" } }); await render(); @@ -471,7 +468,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { it("explains a declined Composio OAuth return without discarding the draft", async () => { mockSearch.value = "source=composio&resume=conn-oauth-draft&oauth=denied&code=oauth_authorization_denied"; - experimentalMock.mockResolvedValue({ enableMcpAggregators: true }); getConnectionMock.mockResolvedValue({ id: "conn-oauth-draft", status: "draft", authKind: "oauth", config: { sourceTemplateKey: "composio", connectionMethodKey: "mcp", url: "https://connect.composio.dev/mcp" } }); await render(); await vi.waitFor(() => expect(container.textContent).toContain("Connection cancelled. Your setup details are preserved")); @@ -485,7 +481,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { ])("retains the %s identity when returning to Access after an OAuth return", async (credentialPolicy, identityLabel, asCurrentUser) => { const draft = { id: "conn-oauth-draft", status: "draft", authKind: "oauth", credentialPolicy, config: { sourceTemplateKey: "composio", connectionMethodKey: "mcp", url: "https://connect.composio.dev/mcp" } }; mockSearch.value = `source=composio&resume=${draft.id}&oauth=denied`; - experimentalMock.mockResolvedValue({ enableMcpAggregators: true }); getConnectionMock.mockResolvedValue(draft); connectAppMock.mockResolvedValue({ connectionId: draft.id, connection: draft, catalog: [], auth: { kind: "oauth" } }); await render(); @@ -2830,7 +2825,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); it("gives Zapier the shared credential and agent access opener", async () => { - experimentalMock.mockResolvedValue({ enableMcpAggregators: true }); mockSearch.value = "source=zapier"; listGalleryMock.mockResolvedValueOnce({ apps: [ diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index 14cdce1326d..b81ae392b23 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -219,23 +219,14 @@ describe("Connectors landing page", () => { for (const slug of ["zep", "supermemory", "cognee", "honcho"]) expect(container.querySelector(`[data-app-slug="${slug}"]`)).toBeNull(); }); - it("hides cached MCP aggregators until enabled and preserves saved MCP connections", async () => { + it("shows all MCP aggregators by default and ignores cached legacy opt-outs", async () => { const providers = ["zapier", "arcade", "composio", "executor"]; listGalleryMock.mockResolvedValue({ apps: [...providers, "notion"].map(getAppStoreDefinition) }); const client = await renderBrowse(); - for (const slug of providers) expect(container.querySelector(`[data-app-slug="${slug}"]`)).toBeNull(); - expect(container.querySelector('[data-app-slug="notion"]')).not.toBeNull(); - await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableMcpAggregators: true }); }); + for (const slug of providers) expect(container.querySelector(`[data-app-slug="${slug}"]`)).not.toBeNull(); + await act(() => { client.setQueryData(queryKeys.instance.experimentalSettings, { enableMcpAggregators: false }); }); await flushReact(); for (const slug of providers) expect(container.querySelector(`[data-app-slug="${slug}"]`)).not.toBeNull(); - await act(() => { - client.setQueryData(queryKeys.tools.connections("company-1"), { connections: [connection({ id: "saved", applicationId: "saved-app", config: { sourceTemplateKey: "composio", connectionMethodKey: "mcp" }, transport: "mcp_remote" })] }); - client.setQueryData(queryKeys.tools.applications("company-1"), { applications: [application({ id: "saved-app", name: "Composio", metadata: { sourceTemplateKey: "composio" } })] }); - client.setQueryData(queryKeys.instance.experimentalSettings, { enableMcpAggregators: false }); - }); - await flushReact(); - expect(container.textContent).toContain("Composio"); - for (const slug of ["zapier", "arcade", "executor"]) expect(container.querySelector(`[data-app-slug="${slug}"]`)).toBeNull(); }); it("defaults to tools-only GitHub and hides chat-only catalog and existing chat accounts", async () => { diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index 945935648d4..e6aa890438b 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -19,7 +19,6 @@ import { import type { ToolApplication, ToolConnection } from "@paperclipai/shared"; import { getAppDefinitionForUrl, - isRemoteMcpConnectorId, isMemoryConnectorId, getAppStoreDefinition, isToolConnectionAttentionHealth, @@ -28,7 +27,6 @@ import { import { useNavigate } from "@/lib/router"; import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled"; import { useMemoryConnectorsEnabled } from "@/hooks/useMemoryConnectorsEnabled"; -import { useMcpAggregatorsEnabled } from "@/hooks/useMcpAggregatorsEnabled"; import { appCopyFor } from "@/lib/app-gallery-copy"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; @@ -284,7 +282,6 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne const { pushToast } = useToast(); const { selectedCompanyId } = useCompany(); const { enabled: chatConnectorsEnabled } = useChatConnectorsEnabled(); - const { enabled: mcpAggregatorsEnabled } = useMcpAggregatorsEnabled(); const { enabled: memoryConnectorsEnabled } = useMemoryConnectorsEnabled(); const { setBreadcrumbs } = useBreadcrumbs(); const [query, setQuery] = useState(""); @@ -368,7 +365,6 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[] ).filter((entry) => { if (!memoryConnectorsEnabled && isMemoryConnectorId(appDefinitionSlug(entry))) return false; - if (!mcpAggregatorsEnabled && isRemoteMcpConnectorId(appDefinitionSlug(entry))) return false; const definition = getAppStoreDefinition(appDefinitionSlug(entry)); return ( chatConnectorsEnabled || diff --git a/ui/storybook/prototypes/RemoteMcpConnectionReview.tsx b/ui/storybook/prototypes/RemoteMcpConnectionReview.tsx index 1616a975d54..abfc994349f 100644 --- a/ui/storybook/prototypes/RemoteMcpConnectionReview.tsx +++ b/ui/storybook/prototypes/RemoteMcpConnectionReview.tsx @@ -11,7 +11,7 @@ import { exampleUrl, fixtureTools, initialReviewState, newFixtureTool, reviewAge /** Provider responses are simulated in memory. The saved connection uses the real * Permissions action list and its real Test dialog, with scoped API fixtures. */ -export function RemoteMcpConnectionReview({ provider, scenario = "journey", inline = false }: { provider: RemoteMcpProviderId; scenario?: ReviewScenario; inline?: boolean }) { +export function RemoteMcpConnectionReview({ provider, scenario = "journey", inline = false, upstreamServiceName }: { upstreamServiceName?: string; provider: RemoteMcpProviderId; scenario?: ReviewScenario; inline?: boolean }) { const connectionId = `review-${provider}`; const client = useMemo(() => new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }), [provider, scenario]); const [s, setState] = useState(() => initialReviewState(provider, scenario)); @@ -123,8 +123,8 @@ export function RemoteMcpConnectionReview({ provider, scenario = "journey", inli
Design review · {remoteMcpProviders[provider].name} — Example accounts and tools. No real sign-in, calls, or credential storage. Use fake values only.
{inline ? Connect {remoteMcpProviders[provider].name} - - : } + + : }