diff --git a/doc/connections/MEMORY.md b/doc/connections/MEMORY.md new file mode 100644 index 00000000000..f2153e618c0 --- /dev/null +++ b/doc/connections/MEMORY.md @@ -0,0 +1,373 @@ +# Experimental memory connectors + +Enable **Settings → Experimental → Memory connectors**, then open **Connectors** +and choose Mem0, Zep, Supermemory, Cognee, or Honcho. The flag defaults to off. +It hides catalog setup and rejects new curated setup and initial OAuth-start +requests on the server. Existing connections keep running and can reconnect or +rotate credentials without re-enabling the toggle. +Generic custom MCP connections retain their existing behavior. + +These are ordinary company-scoped tool connections. There is no separate memory +page, automatic conversation upload, prompt injection, or background memory sync. +Agents explicitly invoke the provider's tools through the existing gateway, and +operators manage access and actions on the regular Permissions screen. + +## Provider contracts + +Official documentation and public endpoint discovery checked September 24, 2026. + +| Provider | Transport and authentication | Setup and scope | +| --- | --- | --- | +| [Mem0](https://docs.mem0.ai/platform/mem0-mcp) | Remote MCP, `https://mcp.mem0.ai/mcp/`, Bearer API key | Create a key in the Mem0 dashboard. The provider key controls project access; user/agent/session selectors are tool arguments. | +| [Zep](https://help.getzep.com/memory-mcp-server) | Remote MCP, `https://api.getzep.com/mcp`, OAuth | Configure Memory MCP and the identity provider in Zep first. A project administrator assigns MCP seats and shared graphs. Sign in with the assigned work identity. An ordinary Zep API key is not the credential for this endpoint. | +| [Supermemory](https://supermemory.ai/docs/supermemory-mcp/mcp) | Remote MCP, `https://mcp.supermemory.ai/mcp`, OAuth | Sign in and select the workspace, read/write access, and optional container tags offered by Supermemory. Developer API keys are separate from hosted MCP sign-in. | +| [Cognee](https://docs.cognee.ai/cognee-cloud/connections/cloud-mcp) | Bundled Cloud API bridge, Cloud API key | Copy the tenant API Base URL and key from Cognee Cloud → API Keys. Requires an active Cloud workspace. The bundled bridge works in public deployments without a local MCP runtime host. | +| [Honcho](https://honcho.dev/docs/v3/guides/integrations/mcp) | Remote MCP, `https://mcp.honcho.dev`, Bearer API key | Create an organization and API key in the Honcho dashboard. Workspace, peer, and session selectors remain explicit provider tool arguments. | + +Zep and Supermemory use user grants, the existing PKCE OAuth broker, and automatic +client registration/discovery. Zep advertises its authorization server at +`https://api.getzep.com/v1/oauth`, with `graph:read graph:write` scopes. +Supermemory advertises `https://api.supermemory.ai/api/auth`, with +`openid profile email offline_access`. Neither requires Paperclip ID or a new +Paperclip-hosted credential service. + +Cognee does not advertise a hosted MCP endpoint. Paperclip bundles a narrow +Cloud API bridge for `remember`, `recall`, and `forget`, matching the reviewed +remote-mode contract from `cognee-mcp` 0.5.5. The existing approved template ID +and credential paths remain compatible, but no process or package manager runs: +there is no runtime registry resolution, transitive dependency installation, or +credential-bearing subprocess or local runtime slot. Provider failures do not +consume local restart budgets. The bridge uses the gateway's guarded HTTP +client and bounded response reader. It sends credentials only to the validated +HTTPS tenant origin under `*.aws.cognee.ai`; redirects, credentials in URLs, +ports, paths, query strings, and fragments are rejected. + +Setup validates Cloud access with the datasets endpoint. Scheduled health +checks validate the URL and vault references without probing the Cloud API, so +a transient provider timeout cannot hide working tools. Actual calls still +report provider failures. Calls without an explicit timeout receive 60 seconds +for retrieval synthesis. Indexing is asynchronous, so an immediate recall after +remember may return HTTP 409 until the dataset is ready. + +## Credential and governance boundaries + +API keys and OAuth tokens use the instance vault and existing grant lifecycle. +Cognee's tenant URL is displayed as text during entry but is also vaulted, with +an `env.COGNEE_BASE_URL` reference. No credential value belongs in connection +config, manifests, logs, fixtures, or Storybook. Personal credentials stay on +personal grants; organization credentials stay on the organization grant. +The local stdio gateway projects only the approved template's environment keys. + +Provider resource selectors are not new Paperclip-enforced tenant filters. The +provider's key, OAuth consent, and ACLs determine its accessible data. Configure +agent access and action policies accordingly; do not claim that a user ID, +workspace name, dataset, or space argument by itself enforces isolation. + +The catalog classifies memory retrieval as read, storage/update as write, and +forget/delete/reset as destructive, overriding misleading read-only hints. +Unknown actions are treated as writes. Supermemory's `add_memory` can either +save or forget, so the whole action is destructive. Cognee exposes only the +reviewed `remember`, `recall`, and `forget` schemas from version 0.5.5, not the +package's broader administration tools. + +| Reviewed family | Examples | Risk | +| --- | --- | --- | +| Retrieval | Mem0 `search_memories`, `get_memories`, `list_events`; Supermemory `search_memory`, `get_profile`; Cognee `recall` | Read | +| Store/update | Mem0 `add_memory`, `update_memory`; Zep add-memory tools; Cognee `remember`; Honcho create/update tools | Write | +| Delete/forget | Mem0 `delete_memory`, `delete_all_memories`, `delete_entities`; Supermemory `add_memory`; Cognee `forget` | Destructive | + +The existing default policy remains **Allowed** for active actions, including +writes and deletion. Operators can narrow access or set **Ask first**. Connection +revocation, company and grant checks, action profiles, runtime access, catalog +quarantine (when enabled), and audit continue through the shared substrate. + +## Review and validation + +Storybook: **Apps → Connections → Memory**. Includes all five production setup +flows, the experimental toggle, disabled setup, waiting for sign-in, rejected +credentials/retry, and a narrow Cognee layout. The fixtures explicitly simulate +authentication/discovery and never call providers or store secrets. + +Deterministic tests cover the off-by-default contract, cached gallery filtering, +direct setup links, server-side gates before secret creation, provider methods, +environment credential paths, risk classification, pinned Cognee schemas, URL +restrictions, and encrypted credential handling. Full provider proof is tracked +separately below; mocked tests and Storybook are not live proof. + +The [sanitized live tool inventory](memory-tool-inventory.json) records every +discovered name, schema hash, parameter name, and risk classification for the +five connected providers (82 tools total). + +Live observations from the isolated `codex/memory-connectors` checkout: + +- Mem0: API key created; live setup discovered 11 tools. Search succeeded through + Paperclip. An `add_memory` call with synthetic notebook text waited for **Ask + first**, then executed after **Allow once**, with both decisions in the audit log. +- Supermemory: Google sign-in and developer API key creation completed. Hosted + MCP OAuth connected separately with read-only consent restricted to the test + tag and one test agent. Search succeeded; a query for an unconsented tag + returned the expected provider denial. Live discovery returned 16 tools. +- Cognee: API key created and Cloud access verified. The official pinned MCP + client stored synthetic notebook text in a dedicated test dataset and recalled + it after indexing completed. Personal setup exposes the three reviewed tools. + Paperclip gateway recall returned the expected blue notebook fact (10.6s). + A nonexistent test dataset returned a tool error, correctly recorded as failure. +- Zep: Google signup and the isolated `Memory connector test` project are ready. + Its project API key is saved in `~/.secrets` (mode 0600), separately from the + hosted MCP OAuth grant. Enabled Google Workspace MCP with writes allowed and + automatic user creation disabled; created only the matching test user using + the documented API. Paperclip dynamic registration and the explicitly approved + `graph:read` / `graph:write` grant completed. Live discovery returned 12 tools. + The Codex browser's automated form submission stalled; a fresh flow and the + user's final consent click completed authorization. +- Honcho: initial organization creation failed repeatedly, including after a + fresh Google login. A subsequent retry succeeded and issued the API key, + saved in `~/.secrets` (mode 0600). Personal connection setup discovered 40 tools. + No payment method was added. The real agent test exposed and verified the + personal remote credential-path correction described below. + +All five providers now have authenticated managed-tool E2E evidence. Supermemory +now also has verified scoped writes and semantic recall, as recorded below. Discovery does +not establish that every individual tool or advanced provider feature was tested. +No real memories were uploaded for testing. The synthetic Cognee dataset is +`paperclip_memory_connector_smoke_20260924`; the Mem0 test user and Supermemory +consent tag are `paperclip-memory-smoke-20260924`. + +### Real agent tasks + +Follow-up acceptance testing creates tasks in the browser and assigns a real +`codex_local` agent in an isolated company. These tests use managed connector +tools, not provider credentials in the agent environment. Task-scoped connector +audit records verify the returned provider results independently of the agent's +completion comment. + +The first task exposed a Codex delivery defect: generated MCP config used +`headers`, which Codex ignores, instead of `http_headers`. The adapter now writes +the supported field; a regression assertion checks the complete header setting. +After restarting the test server, the resumed agent received working managed +tools and retrieved the approved Mem0 memory. + +- **MEM-1 / Mem0:** browser-created task requested a synthetic write, paused for + **Ask first**, and resumed after the board clicked **Approve & run**. The write + executed once and `search_memories` retrieved the same memory ID and exact + silver compass fact under user `paperclip-memory-task-e2e-20260924`. +- **MEM-2 / Cognee:** the task called `remember` and `recall` through the managed + stdio gateway. The provider returned the amber telescope fact from dedicated + dataset `paperclip_memory_task_e2e_20260924` on the first recall. +- **MEM-3 / Supermemory:** read-only search succeeded within the consented tag + with an honest zero-result response; one out-of-scope search returned the + provider's explicit 403 denial. This does not test memory writes under + read-only consent. The task exposed a gateway reporting defect: provider + `isError: true` was recorded and returned as success. Failed provider calls now + produce failed invocation/audit records and an MCP `isError: true` response. + A second browser-triggered agent run confirmed both the successful scoped + search and the corrected error response; audit records show `call_failed`, + `outcome: failure`, and `reasonCode: tool_error` for the denied search. +- **MEM-4 / Zep:** run `959d37a2-5d06-4f8b-bbfd-b4d751949fc8` called + `add_memory` and `search_graph` through the managed connector. The first + episode search returned the exact golden astrolabe fact and matching episode + `9133fe20-3bf0-46e4-bb48-d58c697d141d`. The episode was not yet processed; + raw episode retrieval passed, while derived graph processing is not claimed. +- **MEM-5 / Honcho:** the first authenticated run exposed a gateway bug: a + personal API-key path `credentials.authorization` was incorrectly prefixed a + second time during vault resolution. Personal remote grants now use their + declared path, with regression coverage for API keys, custom headers, and + OAuth. After restart, run `f8498aa1-5c84-48f5-8a94-3b603ea30e9a` made seven + successful managed calls: list/create workspace, create peer/session, add the + peer, add a message, and retrieve messages. Dedicated workspace + `paperclip-memory-e2e-20260924`, peer `synthetic-test-explorer`, session + `synthetic-observatory-session`, message `q3nOttElad3g_JACmizQd` returned the + exact violet sundial fact. No approval or provider errors occurred on the + successful run. Its initial discovery still reflected the earlier failed + health check; successful calls restored connection health. + +All tools default to **Allowed**, including writes and destructive actions. +The temporary Mem0 **Ask first** test override was removed after the approval +test. Effective agent access was checked again: Mem0 11/11, Cognee 3/3, +Supermemory 16/16, Zep 12/12, and Honcho 40/40 allowed, with zero ask-first or +off actions. Provider OAuth consent remains a separate boundary from Paperclip +tool permissions. + +### Daytona sandbox verification (September 24, 2026) + +Browser-created **MEM-6** runs the same three connected providers through a +native `paperclip_runner` Codex agent in a real Daytona Linux x86_64 sandbox. +The acceptance task uses only synthetic data and managed tools: a Mem0 copper +lantern fact, a dedicated Cognee dataset, and Supermemory searches inside and +outside the existing read-only consent. This first run predates Zep and Honcho +account setup; their subsequent local proof appears above. This is a manual +Product E2E attempt, not a full eval campaign. + +The immutable sandbox image is +`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:b782947dc9738038570308686858dfb37fd731aba2f82944b6bb665a419e2f24`. +The remote runner binary was extracted from that image (SHA-256 +`5067194e4a4eff0946e312b162e78a46184dae49c29f5699be81fec6cfd0b9d7`). +The first attempt failed before provider startup because a macOS host needs +`PAPERCLIP_RUNNER_REMOTE_BINARY_PATH` pointing to a Linux binary. After configuring +it, PRP authenticated through Daytona provider ingress and the agent executed in +`/home/daytona/paperclip-workspace` as the `daytona` user. + +That retry did **not** pass connector acceptance: its assigned MCP URL pointed to +the local host's loopback address, which means the sandbox itself when used +remotely. No memory provider calls ran. Catalog readiness from +`connections_search` does not prove remote gateway connectivity. Native assigned +MCP traffic uses a direct HTTP connection, separate from the PRP control channel; +the remote Codex path now relays assigned tools through the existing authenticated +PRP dynamic-tool channel. The control plane retains the short-lived gateway token +and invokes the same gateway service for discovery and each call. Provider +credentials, policies, approval checks, and audit records stay server-owned. +Every call also verifies the native run still owns its task; planning/ask modes +expose only read tools. Other provider/local paths retain their existing HTTP MCP +delivery. No tunnel or public board/API exposure is needed for remote Codex. +Provider threads retain dynamic-tool declarations, so the remote session contract +also rotates when introducing the relay. An intermediate retry discovered all +30 tools server-side but resumed an old provider catalog; it made no provider +calls and is not counted as a pass. + +The first successful browser-triggered run `16c288bc-27c3-4010-b8f8-e3d24d007c59` completed +**MEM-6** successfully in 1m 36s with the default configured `gpt-5.6-sol` model. +Task-scoped gateway audit records independently confirm six calls: + +- Mem0 `add_memory` stored the exact copper lantern fact with `infer=false`; + `search_memories` returned the same ID (`ba30eea2-f68b-4873-acbe-a59dae98ed75`) + under user `paperclip-memory-daytona-e2e-20260924`. +- Cognee `remember` accepted the dedicated + `paperclip_memory_daytona_e2e_20260924` dataset, and `recall` returned the fact + on the first attempt. Its stdio provider process remains on the control plane; + the Daytona agent calls it through PRP. +- Supermemory `search_memory` succeeded within the existing consented tag with + zero results. Exactly one unconsented search returned the expected 403 scope + restriction and was audited as `call_failed` / `tool_error`. + +No approval prompts, public tunnel, direct provider API fallback, or provider +credentials in the agent environment were used. All assigned tools remain +Allowed. This proves the connected Mem0, Cognee, and Supermemory journeys on +remote native Codex; it does not establish live Zep/Honcho access or Supermemory +writes beyond its existing read-only consent. Failed setup attempts are retained +in the task history. The disposable environment is removed after verification. + +After Zep and Honcho onboarding completed, **MEM-6** ran again in a fresh +Daytona sandbox: `b41a45a5-6040-429d-82f3-f048f45205a8`, 2m 4s. The browser +showed Done, and task-scoped gateway audit independently confirmed **10 +successful provider calls across all five providers**: + +- Mem0 retrieved the existing copper lantern memory and matching ID. +- Cognee performed two read-only recalls; the second returned the exact stored + sentence from the dedicated dataset. +- Supermemory completed a search within the existing consented tag (zero results). +- Zep stored the jade sextant fact once and retrieved its exact text through + episode search, with matching UUID `8ef23967-bc98-410e-9e06-03669da5d7dd`. +- Honcho created session `daytona-memory-final-20260924` in the existing synthetic + workspace, added the test peer, stored the ivory compass fact, and retrieved + the exact message `e7g4wGXfWR5_rdb4bk6zN`. + +The agent confirmed Linux x86_64, user `daytona`, and working directory +`/home/daytona/paperclip-workspace`. No unexpected approval or connector error +occurred. The control plane relayed all managed provider calls through the native +runner channel, with no provider keys in the agent environment or public tunnel. +All five connections finished healthy. The original local test-agent configuration +was restored, the disposable environment removed, and Daytona confirmed sandbox +`682ca187-765e-4b61-9038-747d2649e76a` no longer exists. Provider test memories +remain available for inspection. Supermemory writes and advanced derived-memory +features remain outside this test's coverage. + +### Storybook walkthrough checks + +The Memory group includes 21 stories covering the catalog, experimental switch, +all five setup flows, credential forms, OAuth waiting/failure, completion, and +narrow layouts. Zep and Supermemory now fetch identity fixtures after mount; +preseeded query-cache data previously left their “Which humans” step waiting +forever. Browser walkthroughs reach both providers' ready screens with all tools +allowed. Credential play functions wait for the access button to become enabled; +browser checks also confirmed rejected Mem0 credentials and narrow Cognee inputs. +These are simulated provider journeys and do not replace live account tests. + +### Automated checks + +- `pnpm -r typecheck` and `pnpm build` passed. The final server changes also + passed `tsc --noEmit`. +- Connector service, memory governance, and instance-setting regressions: 399 + tests passed with embedded PostgreSQL enabled. +- Focused shared/UI contract and setup tests: 281 passed. +- Storybook production build, token gates, brand asset validation, and + `git diff --check` passed. Browser review covered both themes, the 320px + Storybook viewport, disabled setup, the toggle, OAuth waiting, and rejected-key + retry. Storybook authentication remains simulated. +- The full repository suite finished: 686 files passed, six failed, four skipped; + 13,308 tests passed, 11 failed, 84 skipped. It reported failures in Slack + callback batching, concurrent workspace port allocation, and deferred heartbeat + comment batching. Initial connector count/error and instance-setting snapshot + failures were corrected and their full targeted suites passed. That run also + loaded memory-normalization code before the final implementation was saved; + the final targeted memory suite passed. The full suite is not claimed green. +- Codex managed-home regression suite: 53 passed after the live-task fix; + adapter typecheck and build passed. +- Gateway acceptance/service regression suites: 106 passed after the personal + remote credential-path fix; final server typecheck and build passed. +- Native relay/session/authority regressions: 534 passed, followed by 36 final + relay/authority checks covering readable tool names and live task-mode changes. + Final server typecheck and build passed. Relay checks cover configured gateway + reuse, gateway errors, revocation, partial bindings, credential isolation, and + planning/ask restrictions. + +### Supermemory write completion (September 24) + +The original read-only grant was replaced through the browser with a new +read/write OAuth grant restricted to `paperclip-memory-smoke-20260924` and the +same test agent. The old local connection was removed. `who_am_i` independently +returned scoped `permission: write`; all 16 Paperclip tools remain Allowed. + +Local run `fb67a991-2960-47e5-8f79-94282c65d2ec` saved the exact synthetic fact +“The Supermemory test navigator keeps a turquoise compass in a maple cabinet.” +Document `zUk5wpBu79rCMWu9Yubd9j` initially stayed queued, so the task correctly +did not claim immediate recall. Supermemory's browser console later displayed +Done and the exact stored content. + +Daytona run `bab3abd3-617e-4598-b0de-213d5c26f075` performed a second write: +“The Daytona Supermemory test pilot keeps a coral sextant in a walnut drawer.” +Document `C5HY2CWANHZZa3TckJUDYN` reached Done. Managed `get_document` returned +its exact content; `search_memory` returned both this fact and the earlier +local fact at 93% relevance to their respective queries. The task audit records +10 successful calls, including the write, document reads, and semantic searches. +No provider credentials entered the sandbox. The native PRP relay handled all +calls without a public tunnel. The local agent configuration was restored, and +sandbox `6a9723dd-972e-4933-b227-6b8e7e2133ae` was deleted and verified absent. + +### Bundled Cognee bridge acceptance (September 24) + +The final implementation was retested after replacing the external MCP client +with the bundled Cloud bridge. Local managed write run +`16e7820c-2a94-41d9-8d7b-abe6524edf01` stored the bronze-telescope fact in +`paperclip_memory_bundled_bridge_20260924`; the immediate recall did not yet +prove indexing completion. Follow-up run `8e12cad1-f2f4-4471-a790-d589ffa050b7` +returned “He keeps a bronze telescope in a birch observatory.” + +Final Daytona run `a56f9f97-3fb3-498c-abf6-974ddc7d5d30` independently recalled +that fact, then stored “The Daytona Cognee test cartographer keeps a silver +globe in a cedar study.” in `paperclip_memory_daytona_bridge_20260924`. +The provider returned dataset `9518ab46-e777-5f38-994b-9ceabfff4856`, then graph +recall returned the silver globe and cedar study. Every call used the native +runner relay and reported `transport: cognee_cloud`, `spawnedLocalProcess: false`. +The run succeeded. No provider credentials entered the sandbox. The original +local agent configuration was restored, and sandbox +`3b980bc0-6bcd-4cfe-9b17-b518c1aed64a` was deleted and verified absent. + +The gateway regression suite passed all 71 tests, including immediate recovery +from a provider error in an authenticated public deployment without a trusted +local runtime host. The regression asserts that no runtime slot is created. +The bundled bridge's 25 contract and validation tests also passed. + +## Branding provenance + +Official artwork downloaded September 24, 2026; no invented marks or colors: + +- Zep: `https://www.getzep.com/apple-touch-icon.png` (official gradient mark). +- Supermemory: `https://supermemory.ai/apple-touch-icon.png?v=2` (official mark on + its supplied white background). +- Cognee: `https://www.cognee.ai/favicon.ico`, converted losslessly to PNG. +- Honcho: `https://honcho.dev/Honcho_Pixel-05.svg`. +- Mem0 reuses the existing catalog artwork. + +The supplied square artwork fits the shared gray connector frame and works in +both themes. Registry changes are in `ui/public/brands/apps/manifest.json`; +regenerate definitions with `node scripts/ingest-app-definitions.mjs --definitions-only`. diff --git a/doc/connections/README.md b/doc/connections/README.md index f604effdba6..ac7196cd2bf 100644 --- a/doc/connections/README.md +++ b/doc/connections/README.md @@ -10,6 +10,8 @@ proof, and PR submission. Runtime authentication: [AI Connections](./AI-CONNECTIONS.md). +Long-term memory: [Experimental memory connectors](./MEMORY.md). + Provider notes: [Google Workspace](./GOOGLE-WORKSPACE.md), [Gmail](./GMAIL.md), [PostHog](./POSTHOG.md), [AgentMail](./AGENTMAIL.md), and [iMessage Photon](./IMESSAGE-PHOTON.md). Optional credential custody: diff --git a/doc/connections/memory-tool-inventory.json b/doc/connections/memory-tool-inventory.json new file mode 100644 index 00000000000..d161dadb94b --- /dev/null +++ b/doc/connections/memory-tool-inventory.json @@ -0,0 +1,950 @@ +{ + "observedOn": "2026-09-24", + "note": "Live discovery, not proof that every action executes. Active tools require a granted agent. Allowed is the existing default; provider consent and operator policies can narrow it. Cognee exposes only three reviewed tools. All five providers have authenticated discovery; this does not imply every action was executed.", + "providers": [ + { + "provider": "mem0", + "tools": [ + { + "name": "delete_memory", + "schemaHash": "0178804f2233039b7cec699e90097f601025b76789d0b6823fda1fa639183761", + "risk": "destructive", + "status": "active", + "parameters": [ + "memory_id" + ] + }, + { + "name": "delete_entities", + "schemaHash": "0ca77774e6aa257a79504a8395eb4c230b65deeb365efcfb87023e2904d44c23", + "risk": "destructive", + "status": "active", + "parameters": [ + "app_id", + "run_id", + "user_id", + "agent_id" + ] + }, + { + "name": "get_event_status", + "schemaHash": "8bf86b31015e189cd66dc590b9034f40114c88bdbd888da3b205946e8387fafd", + "risk": "read", + "status": "active", + "parameters": [ + "event_id" + ] + }, + { + "name": "list_entities", + "schemaHash": "173f6a9b0b2d4f0bcb0e09b32cb1ab071367ea441e91cd21b80e521068e3dbe6", + "risk": "read", + "status": "active", + "parameters": [] + }, + { + "name": "search_memories", + "schemaHash": "e21f843ff32d984b5f81e3759d1a47d6d9b66d6ba445f217727e347912cdee06", + "risk": "read", + "status": "active", + "parameters": [ + "query", + "top_k", + "rerank", + "filters", + "threshold" + ] + }, + { + "name": "get_memories", + "schemaHash": "b7658cfe2f8e28467b34515c1b58be591be43d5fabfe78cead511cd0a694ece9", + "risk": "read", + "status": "active", + "parameters": [ + "page", + "filters", + "page_size" + ] + }, + { + "name": "list_events", + "schemaHash": "a0c4607c27b1793c15ef27403240535e4c85de9bac379ec36dbd584bcf0789d7", + "risk": "read", + "status": "active", + "parameters": [ + "page", + "page_size", + "event_type" + ] + }, + { + "name": "get_memory", + "schemaHash": "172789759d2c09fc4c121dfd2879ebc4e4017b86fb24c44fece86710f05177b2", + "risk": "read", + "status": "active", + "parameters": [ + "memory_id" + ] + }, + { + "name": "update_memory", + "schemaHash": "9a7a48567fbeee73814213f652b4b5be948713b3fb83daefe384319bc6b2f917", + "risk": "write", + "status": "active", + "parameters": [ + "text", + "metadata", + "memory_id" + ] + }, + { + "name": "add_memory", + "schemaHash": "6fe7a7d12c98939797ba039e2f29168364d89679e3beac3979d90c611205017f", + "risk": "write", + "status": "active", + "parameters": [ + "text", + "infer", + "app_id", + "run_id", + "user_id", + "agent_id", + "messages", + "metadata" + ] + }, + { + "name": "delete_all_memories", + "schemaHash": "771a1a9741504ed1d620bfd8b8ae623006885bb3188ac24987c38bc406be4852", + "risk": "destructive", + "status": "active", + "parameters": [ + "app_id", + "run_id", + "user_id", + "agent_id" + ] + } + ] + }, + { + "provider": "supermemory", + "tools": [ + { + "name": "add_memory", + "schemaHash": "86e7c1f8f5ac6bbc0681f2d1c9013b388c80ce92866482820af98b1b252394f4", + "risk": "destructive", + "status": "active", + "parameters": [ + "action", + "content", + "containerTag" + ] + }, + { + "name": "guided-save", + "schemaHash": "34a6ba1bc76eea4b4ea114dbd2d165e635ac59d1e9a2db95f9b0f978b2738c2c", + "risk": "write", + "status": "active", + "parameters": [ + "prefill" + ] + }, + { + "name": "get_profile", + "schemaHash": "d6d7d9fcee64a3c65a805dee5cc1aa9d3e743dc1d2c2d986967b02e92b602699", + "risk": "read", + "status": "active", + "parameters": [ + "containerTag" + ] + }, + { + "name": "get_document", + "schemaHash": "40b366e576f748c471a0a88b2f5543d0fb3f61529686d14b2980cfa53827969a", + "risk": "read", + "status": "active", + "parameters": [ + "documentId" + ] + }, + { + "name": "fetch-graph-data", + "schemaHash": "3de9177b646fbc038c1c53065071c725b8738830e4e5710e439ef504de133ebd", + "risk": "write", + "status": "active", + "parameters": [ + "page", + "limit", + "containerTag" + ] + }, + { + "name": "list_spaces", + "schemaHash": "93ab7499dc3c616f8db8780fed0d9f69270803cda913882ad2ef3943db8d7225", + "risk": "read", + "status": "active", + "parameters": [] + }, + { + "name": "who_am_i", + "schemaHash": "93ab7499dc3c616f8db8780fed0d9f69270803cda913882ad2ef3943db8d7225", + "risk": "read", + "status": "active", + "parameters": [] + }, + { + "name": "select-space", + "schemaHash": "93ab7499dc3c616f8db8780fed0d9f69270803cda913882ad2ef3943db8d7225", + "risk": "write", + "status": "active", + "parameters": [] + }, + { + "name": "set-active-tag", + "schemaHash": "ec6d6baa715482bb4993a5e4b6dea8ce0257d44ae0508147b97f7c33745844f5", + "risk": "write", + "status": "active", + "parameters": [ + "viewId", + "containerTag" + ] + }, + { + "name": "memory-graph", + "schemaHash": "d6d7d9fcee64a3c65a805dee5cc1aa9d3e743dc1d2c2d986967b02e92b602699", + "risk": "write", + "status": "active", + "parameters": [ + "containerTag" + ] + }, + { + "name": "upload-file", + "schemaHash": "93ab7499dc3c616f8db8780fed0d9f69270803cda913882ad2ef3943db8d7225", + "risk": "write", + "status": "active", + "parameters": [] + }, + { + "name": "prepare-file-upload", + "schemaHash": "93ab7499dc3c616f8db8780fed0d9f69270803cda913882ad2ef3943db8d7225", + "risk": "write", + "status": "active", + "parameters": [] + }, + { + "name": "search_memory", + "schemaHash": "849932adedb079d4dd9e14f7a118dd98dea02be9127c9949075cd05c44d3769f", + "risk": "read", + "status": "active", + "parameters": [ + "query", + "containerTag" + ] + }, + { + "name": "list_documents", + "schemaHash": "e8fbd86a7359a3445acd2808cfb4a3cd56e577e8316e084f855664bad4836130", + "risk": "read", + "status": "active", + "parameters": [ + "page", + "limit", + "containerTag" + ] + }, + { + "name": "list_memories", + "schemaHash": "69980f2255bdbc5ada542b92d6d65181b65e54187a7365f7f220fdd439720d48", + "risk": "read", + "status": "active", + "parameters": [ + "page", + "limit", + "containerTag" + ] + }, + { + "name": "save-memory", + "schemaHash": "ee3ce3f24cb3fd86b64427bc3a6aac9dd62950a5673d4e4d28473a96c3c8ca84", + "risk": "write", + "status": "active", + "parameters": [ + "viewId", + "content", + "containerTag" + ] + } + ] + }, + { + "provider": "cognee", + "tools": [ + { + "name": "forget", + "schemaHash": "a2cd38085419a8adb22cb7adc0724ad6cc83b65910a8eaccd553eaccf1be3d29", + "risk": "destructive", + "status": "active", + "parameters": [ + "dataset", + "everything" + ] + }, + { + "name": "remember", + "schemaHash": "0b7303340d89c018050f49fbcd4af0cb041a1b2878693e6494eadc9cd4c75f94", + "risk": "write", + "status": "active", + "parameters": [ + "data", + "session_id", + "dataset_name", + "custom_prompt" + ] + }, + { + "name": "recall", + "schemaHash": "b6288c8c7996f3026f8b89ab06eb4339c1f5eb220c62294462ec569f1b6580f4", + "risk": "read", + "status": "active", + "parameters": [ + "query", + "top_k", + "datasets", + "session_id", + "search_type", + "system_prompt" + ] + } + ] + }, + { + "provider": "zep", + "tools": [ + { + "name": "add_memory", + "schemaHash": "cc1fa3da352e2b606468e6f912113b1d0bdd94002ecffe871b7d1b9d040b4c85", + "risk": "write", + "status": "active", + "parameters": [ + "data", + "type", + "source_description" + ] + }, + { + "name": "get_user_summary", + "schemaHash": "cd1a463c46d6264134447db17a8c3c7abe5b9a2488c6d759fea66da1f96b133e", + "risk": "read", + "status": "active", + "parameters": [] + }, + { + "name": "add_memory_to_graph", + "schemaHash": "3034a29652a844463cde62d50c1f43beb4b383b7d8c98cec1972b73ec704a14c", + "risk": "write", + "status": "active", + "parameters": [ + "data", + "type", + "graph_id", + "source_description" + ] + }, + { + "name": "list_graphs", + "schemaHash": "dd49ca9de985df8ca3e629309bcfe46ebb4aefbba4ee2ba0f69573421ef03929", + "risk": "read", + "status": "active", + "parameters": [ + "page", + "limit", + "search" + ] + }, + { + "name": "search_graph", + "schemaHash": "17d12f0311566c62c0940877bc9444c53191fa14a2e5476aa6f9d805a0d5f04f", + "risk": "read", + "status": "active", + "parameters": [ + "limit", + "query", + "scope" + ] + }, + { + "name": "search_graph_in", + "schemaHash": "ecdbfa6293af4f379c6b1647cb808f0fc66a569dae5604d34b3f5afdab6f4042", + "risk": "read", + "status": "active", + "parameters": [ + "limit", + "query", + "scope", + "graph_id" + ] + }, + { + "name": "get_node_neighbors", + "schemaHash": "417b8cccb39ce55e0ce09f02557eb3d23a26ae60eb44bf7df13c12b305d4d9a9", + "risk": "read", + "status": "active", + "parameters": [ + "limit", + "cursor", + "filters", + "order_by", + "direction", + "node_uuid", + "direction_sort" + ] + }, + { + "name": "get_subgraph", + "schemaHash": "82192cbcacaca654063ada7851d5ada9f6a99dfc0f4c23e3302892f8c5825a68", + "risk": "read", + "status": "active", + "parameters": [ + "depth", + "filters", + "direction", + "max_edges", + "max_nodes", + "seed_node_uuids" + ] + }, + { + "name": "get_subgraph_in", + "schemaHash": "0949758fa1b4c97205e4d2a1ae34bb55f48eb1b327c2e042ccf9d3ea5786c381", + "risk": "read", + "status": "active", + "parameters": [ + "depth", + "filters", + "graph_id", + "direction", + "max_edges", + "max_nodes", + "seed_node_uuids" + ] + }, + { + "name": "list_episodes_in", + "schemaHash": "e654928fccef680ab7a0460a0ce504d12818e3f3d29e9666045601d42f1cc637", + "risk": "read", + "status": "active", + "parameters": [ + "limit", + "cursor", + "graph_id", + "order_by", + "direction", + "mentioned_node_uuids" + ] + }, + { + "name": "get_node_neighbors_in", + "schemaHash": "192a1df67d45bae70390c948ca2202a52b67797996d42c9c02e0332ca2d20bd2", + "risk": "read", + "status": "active", + "parameters": [ + "limit", + "cursor", + "filters", + "graph_id", + "order_by", + "direction", + "node_uuid", + "direction_sort" + ] + }, + { + "name": "list_episodes", + "schemaHash": "f0d7e9aefbac757fbe08815baad7d57ce6aa6cc00408677a16a4e09d8012aa67", + "risk": "read", + "status": "active", + "parameters": [ + "limit", + "cursor", + "order_by", + "direction", + "mentioned_node_uuids" + ] + } + ] + }, + { + "provider": "honcho", + "tools": [ + { + "name": "inspect_workspace", + "schemaHash": "7006ccbca84d1b922813b151e71e912a94036b941971232947f526180c2fb1fc", + "risk": "write", + "status": "active", + "parameters": [ + "workspace_id" + ] + }, + { + "name": "get_peer_card", + "schemaHash": "6e85032dadc141436cb9b80c3714aeadd34750a21939d3acb56a256fc3e91b68", + "risk": "read", + "status": "active", + "parameters": [ + "peer_id", + "workspace_id", + "target_peer_id" + ] + }, + { + "name": "get_session_peers", + "schemaHash": "7d164405c67cd4aac902d49700466ac6c288c03362791568c2840994451a4c2b", + "risk": "read", + "status": "active", + "parameters": [ + "session_id", + "workspace_id" + ] + }, + { + "name": "get_scope_status", + "schemaHash": "709c463cbb121fd2c80a047e158ed9f3f695765926120a069e020af460a81d4e", + "risk": "read", + "status": "active", + "parameters": [ + "scope_id", + "workspace_id" + ] + }, + { + "name": "create_session", + "schemaHash": "ccc7166acc005bd83e1bd85d0a321ada90edfd7db6a642f8a16b6f851ac78790", + "risk": "write", + "status": "active", + "parameters": [ + "session_id", + "workspace_id" + ] + }, + { + "name": "get_metadata", + "schemaHash": "27323be21281dccad06980f266b3e7bbc90435bc92a5d13193be8bdad8998cde", + "risk": "read", + "status": "active", + "parameters": [ + "peer_id", + "session_id", + "workspace_id" + ] + }, + { + "name": "search", + "schemaHash": "ebcbcc99a6cb02a09ee87c288f85075d2b21edfea2d70800515c72edc2417be1", + "risk": "read", + "status": "active", + "parameters": [ + "query", + "peer_id", + "session_id", + "workspace_id", + "message_limit", + "message_filters", + "conclusion_top_k", + "conclusion_filters" + ] + }, + { + "name": "get_representation", + "schemaHash": "8f6462cfe1017af82843980ffcf65e04033a68b7a5b555b1aa3712dff8fc8c53", + "risk": "read", + "status": "active", + "parameters": [ + "peer_id", + "session_id", + "search_query", + "workspace_id", + "target_peer_id", + "max_conclusions" + ] + }, + { + "name": "get_session_messages", + "schemaHash": "6b18662606a05abf1b530b883ea93ade58db1716f61480919f1710d965f07c7f", + "risk": "read", + "status": "active", + "parameters": [ + "page", + "size", + "filters", + "reverse", + "session_id", + "workspace_id" + ] + }, + { + "name": "list_workspaces", + "schemaHash": "a658aeee9ff944ad6557420adc9b6541c13f0324f76db9f0cbc809fd575aa73f", + "risk": "read", + "status": "active", + "parameters": [ + "page", + "size" + ] + }, + { + "name": "set_peer_card", + "schemaHash": "bb8488395f63f6d190bb86f2978a20191e4d9f2c7ed41d39c55cc1a996cd175a", + "risk": "write", + "status": "active", + "parameters": [ + "peer_id", + "peer_card", + "workspace_id", + "target_peer_id" + ] + }, + { + "name": "inspect_session", + "schemaHash": "e4323044b0e83f4165fbe0a81ac3286a33f87147e185a5fc3610b7c77b4e32ca", + "risk": "write", + "status": "active", + "parameters": [ + "session_id", + "workspace_id" + ] + }, + { + "name": "get_scope_sessions", + "schemaHash": "999d703182f18d1319f0caaeb290fb59fea3a52431da5a4b20347882dd0610ac", + "risk": "read", + "status": "active", + "parameters": [ + "page", + "size", + "scope_id", + "workspace_id" + ] + }, + { + "name": "create_workspace", + "schemaHash": "62d6d24f2eadc6e22c3930e2413105077b102f30bb5741c63513cddf41a52aff", + "risk": "write", + "status": "active", + "parameters": [ + "metadata", + "workspace_id" + ] + }, + { + "name": "get_peer_context", + "schemaHash": "141debc8537d949ab4243dfd1aad84c8101eb50fff8f445bd0019294aa5ddfa0", + "risk": "read", + "status": "active", + "parameters": [ + "peer_id", + "search_query", + "workspace_id", + "target_peer_id", + "max_conclusions" + ] + }, + { + "name": "add_messages_to_session", + "schemaHash": "fed41788407fcfc7b5c0374885303d1c33186fee73ce471bdd37ca6e1810274e", + "risk": "write", + "status": "active", + "parameters": [ + "messages", + "session_id", + "workspace_id" + ] + }, + { + "name": "list_conclusions", + "schemaHash": "19f0672eaed5b4d6563bb7457bf8eb6600640aa6b99578b0ab9283e1a3496193", + "risk": "read", + "status": "active", + "parameters": [ + "page", + "size", + "filters", + "peer_id", + "reverse", + "session_id", + "workspace_id", + "target_peer_id" + ] + }, + { + "name": "query_conclusions", + "schemaHash": "65e4123ca83fee2a8264fe65f79a8c78fa408f7ca11597a8b1add9fb9dd5d300", + "risk": "read", + "status": "active", + "parameters": [ + "query", + "top_k", + "filters", + "peer_id", + "workspace_id", + "target_peer_id" + ] + }, + { + "name": "workspace_chat", + "schemaHash": "a3fc62bdce51ab51a3d9557e7ca7c28bddfc7f65981e0fb2ed8161e1e262ce1d", + "risk": "write", + "status": "active", + "parameters": [ + "query", + "scope", + "session_id", + "workspace_id", + "reasoning_level", + "include_evidence" + ] + }, + { + "name": "get_session_message", + "schemaHash": "b0d31acd81c0b082ade1c3ce5b3734ef2b708d08544b75152933ef36a01002b0", + "risk": "read", + "status": "active", + "parameters": [ + "message_id", + "session_id", + "workspace_id" + ] + }, + { + "name": "get_conclusions", + "schemaHash": "372ee91843c4d49041f1f815fc08dd87718241094d1a33aa5ac50a501fe08626", + "risk": "read", + "status": "active", + "parameters": [ + "workspace_id", + "conclusion_ids" + ] + }, + { + "name": "list_sessions", + "schemaHash": "c7719d0d1f268594d2db4543a3771d206c7ab093e8e11fe43bd6225d04b18160", + "risk": "read", + "status": "active", + "parameters": [ + "page", + "size", + "reverse", + "workspace_id" + ] + }, + { + "name": "get_session_context", + "schemaHash": "efb01feb707fc4c7c043af1567661046a4ad801f0ff349985d7e6ab295091c40", + "risk": "read", + "status": "active", + "parameters": [ + "tokens", + "summary", + "session_id", + "workspace_id" + ] + }, + { + "name": "get_derived_conclusions", + "schemaHash": "f0cfc0414bb2f37084574e4574962b3cb38e0ac90a8d90e7a2f4bd6352b83d53", + "risk": "read", + "status": "active", + "parameters": [ + "page", + "size", + "workspace_id", + "conclusion_id" + ] + }, + { + "name": "set_metadata", + "schemaHash": "c4825215baad248cb1e1829ec4aadf14f5c0606aa540845fc961e220a04b77bc", + "risk": "write", + "status": "active", + "parameters": [ + "peer_id", + "metadata", + "session_id", + "workspace_id" + ] + }, + { + "name": "delete_session", + "schemaHash": "2b27006c10e7f7a33381ccc8d2beb6ce2b9b0166b268f86cc487ca993654c133", + "risk": "destructive", + "status": "active", + "parameters": [ + "session_id", + "workspace_id" + ] + }, + { + "name": "list_scopes", + "schemaHash": "7d9e05d863afbdd19411e16a19b6ae1e0db6f8b12df9908a02b11226c94f59a9", + "risk": "read", + "status": "active", + "parameters": [ + "page", + "size", + "workspace_id" + ] + }, + { + "name": "create_conclusions", + "schemaHash": "7c1b8a08b7f57b59a5ea714ada9625fc28695ddb6aa663192a375a4ec511bf3b", + "risk": "write", + "status": "active", + "parameters": [ + "peer_id", + "session_id", + "conclusions", + "workspace_id", + "target_peer_id" + ] + }, + { + "name": "create_peer", + "schemaHash": "4dba9bb190cabe6783305a7deeb5ad40fbab40941ef5a9053b7d07cd0c07397a", + "risk": "write", + "status": "active", + "parameters": [ + "peer_id", + "workspace_id", + "configuration" + ] + }, + { + "name": "clone_session", + "schemaHash": "4c7a2c9db91039b4598bae3745df1e96b58013483349c8c1dc9a0c83647897e4", + "risk": "write", + "status": "active", + "parameters": [ + "message_id", + "session_id", + "workspace_id" + ] + }, + { + "name": "create_scope", + "schemaHash": "0b5c75cf5fbcd39bb7949b1dbfb7637958e9618d9164719a0335703110f8e1ce", + "risk": "write", + "status": "active", + "parameters": [ + "metadata", + "scope_id", + "workspace_id" + ] + }, + { + "name": "delete_conclusion", + "schemaHash": "c9d4c97b02672a94bd1363d4c79078566e8817e3116519e2838416da023ce225", + "risk": "destructive", + "status": "active", + "parameters": [ + "peer_id", + "workspace_id", + "conclusion_id", + "target_peer_id" + ] + }, + { + "name": "list_peers", + "schemaHash": "7006ccbca84d1b922813b151e71e912a94036b941971232947f526180c2fb1fc", + "risk": "read", + "status": "active", + "parameters": [ + "workspace_id" + ] + }, + { + "name": "add_peers_to_session", + "schemaHash": "59001ee3487856fec80fc7292473f1f04b800b94eb210abe196c8e2d43ab5781", + "risk": "write", + "status": "active", + "parameters": [ + "peers", + "session_id", + "workspace_id" + ] + }, + { + "name": "add_sessions_to_scope", + "schemaHash": "8a7124f64b416af308a658c17798fa623c2be5bacfc65bc90aecdb5f3e896213", + "risk": "write", + "status": "active", + "parameters": [ + "scope_id", + "session_ids", + "workspace_id" + ] + }, + { + "name": "schedule_dream", + "schemaHash": "0b55cca23970fa8f2ec70682d9f1920529ec071f95f13cd7d8e743fc2c25d81d", + "risk": "write", + "status": "active", + "parameters": [ + "peer_id", + "session_id", + "workspace_id", + "target_peer_id" + ] + }, + { + "name": "chat", + "schemaHash": "06abdd3077b3494be03be64e2d501654afa74d9599cb9ceac7c6b81636b56363", + "risk": "write", + "status": "active", + "parameters": [ + "query", + "scope", + "peer_id", + "sessions", + "session_id", + "workspace_id", + "target_peer_id", + "reasoning_level", + "include_evidence" + ] + }, + { + "name": "remove_peers_from_session", + "schemaHash": "7d10f74baffef038df46230abe8da948560b577f49b6a89cf38962fc2c248431", + "risk": "destructive", + "status": "active", + "parameters": [ + "peer_ids", + "session_id", + "workspace_id" + ] + }, + { + "name": "remove_session_from_scope", + "schemaHash": "ae48f9cab61e27e86c1c12dce0b3c4fe59b822adaec80ad60017cc878c846ea1", + "risk": "destructive", + "status": "active", + "parameters": [ + "scope_id", + "session_id", + "workspace_id" + ] + }, + { + "name": "get_queue_status", + "schemaHash": "7006ccbca84d1b922813b151e71e912a94036b941971232947f526180c2fb1fc", + "risk": "read", + "status": "active", + "parameters": [ + "workspace_id" + ] + } + ] + } + ] +} diff --git a/packages/adapters/codex-local/src/server/codex-home.test.ts b/packages/adapters/codex-local/src/server/codex-home.test.ts index 5761d9756d7..13cb166c18e 100644 --- a/packages/adapters/codex-local/src/server/codex-home.test.ts +++ b/packages/adapters/codex-local/src/server/codex-home.test.ts @@ -1023,7 +1023,8 @@ describe("evaluateCodexCredentialReadiness", () => { const alpha = await fs.readFile(path.join(alphaHome, "config.toml"), "utf8"); const zero = await fs.readFile(path.join(zeroHome, "config.toml"), "utf8"); expect(alpha).toContain('[mcp_servers."alpha"]'); - expect(alpha).toContain('Authorization = "Bearer alpha-token"'); + expect(alpha).toContain('http_headers = { Authorization = "Bearer alpha-token" }'); + expect(alpha).not.toMatch(/^headers\s*=/m); expect(zero).not.toContain("mcp_servers."); expect(zero).not.toContain("stale-token"); expect(alphaHome).not.toBe(zeroHome); diff --git a/packages/adapters/codex-local/src/server/codex-home.ts b/packages/adapters/codex-local/src/server/codex-home.ts index b19a6d1d5da..f6b5f968da0 100644 --- a/packages/adapters/codex-local/src/server/codex-home.ts +++ b/packages/adapters/codex-local/src/server/codex-home.ts @@ -316,7 +316,7 @@ function buildManagedMcpBlock(input: { "", `[mcp_servers.${tomlString(managedName)}]`, `url = ${tomlString(url)}`, - `headers = { Authorization = ${tomlString(`Bearer ${gateway.bearerToken}`)} }`, + `http_headers = { Authorization = ${tomlString(`Bearer ${gateway.bearerToken}`)} }`, ); }); lines.push(MANAGED_MCP_BLOCK_END); diff --git a/packages/shared/src/app-definitions.generated.ts b/packages/shared/src/app-definitions.generated.ts index 030742171d6..3a044920cdc 100644 --- a/packages/shared/src/app-definitions.generated.ts +++ b/packages/shared/src/app-definitions.generated.ts @@ -20,58 +20,62 @@ import a18 from "./app-definitions/api-key-generic.json" with { type: "json" }; import a19 from "./app-definitions/sentry.json" with { type: "json" }; import a20 from "./app-definitions/vercel.json" with { type: "json" }; import a21 from "./app-definitions/anthropic.json" with { type: "json" }; -import a22 from "./app-definitions/jira.json" with { type: "json" }; -import a23 from "./app-definitions/airtable.json" with { type: "json" }; -import a24 from "./app-definitions/beehiiv.json" with { type: "json" }; -import a25 from "./app-definitions/bitly.json" with { type: "json" }; -import a26 from "./app-definitions/candid.json" with { type: "json" }; -import a27 from "./app-definitions/cloudflare.json" with { type: "json" }; -import a28 from "./app-definitions/cloudinary.json" with { type: "json" }; -import a29 from "./app-definitions/coda.json" with { type: "json" }; -import a30 from "./app-definitions/hugging-face.json" with { type: "json" }; -import a31 from "./app-definitions/kernel.json" with { type: "json" }; -import a32 from "./app-definitions/local-falcon.json" with { type: "json" }; -import a33 from "./app-definitions/make.json" with { type: "json" }; -import a34 from "./app-definitions/manufact.json" with { type: "json" }; -import a35 from "./app-definitions/miro.json" with { type: "json" }; -import a36 from "./app-definitions/netlify.json" with { type: "json" }; -import a37 from "./app-definitions/oreilly.json" with { type: "json" }; -import a38 from "./app-definitions/planetscale.json" with { type: "json" }; -import a39 from "./app-definitions/resend.json" with { type: "json" }; -import a40 from "./app-definitions/ticktick.json" with { type: "json" }; -import a41 from "./app-definitions/todoist.json" with { type: "json" }; -import a42 from "./app-definitions/webflow.json" with { type: "json" }; -import a43 from "./app-definitions/wix.json" with { type: "json" }; -import a44 from "./app-definitions/brex.json" with { type: "json" }; -import a45 from "./app-definitions/clickhouse.json" with { type: "json" }; -import a46 from "./app-definitions/egnyte.json" with { type: "json" }; -import a47 from "./app-definitions/embat.json" with { type: "json" }; -import a48 from "./app-definitions/mixpanel.json" with { type: "json" }; -import a49 from "./app-definitions/postman.json" with { type: "json" }; -import a50 from "./app-definitions/razorpay.json" with { type: "json" }; -import a51 from "./app-definitions/sanity.json" with { type: "json" }; -import a52 from "./app-definitions/stripe.json" with { type: "json" }; -import a53 from "./app-definitions/supabase.json" with { type: "json" }; -import a54 from "./app-definitions/ticket-tailor.json" with { type: "json" }; -import a55 from "./app-definitions/asana.json" with { type: "json" }; -import a56 from "./app-definitions/box.json" with { type: "json" }; -import a57 from "./app-definitions/mem0.json" with { type: "json" }; -import a58 from "./app-definitions/pagerduty.json" with { type: "json" }; -import a59 from "./app-definitions/similarweb.json" with { type: "json" }; -import a60 from "./app-definitions/xero.json" with { type: "json" }; -import a61 from "./app-definitions/youcom.json" with { type: "json" }; -import a62 from "./app-definitions/fireflies.json" with { type: "json" }; -import a63 from "./app-definitions/gmail.json" with { type: "json" }; -import a64 from "./app-definitions/google-drive.json" with { type: "json" }; -import a65 from "./app-definitions/google-docs.json" with { type: "json" }; -import a66 from "./app-definitions/google-sheets.json" with { type: "json" }; -import a67 from "./app-definitions/google-slides.json" with { type: "json" }; -import a68 from "./app-definitions/google-calendar.json" with { type: "json" }; -import a69 from "./app-definitions/google-chat.json" with { type: "json" }; -import a70 from "./app-definitions/google-people.json" with { type: "json" }; -import a71 from "./app-definitions/google-workspace-search.json" with { type: "json" }; -import a72 from "./app-definitions/openai.json" with { type: "json" }; -import a73 from "./app-definitions/openrouter.json" with { type: "json" }; -import a74 from "./app-definitions/xai.json" with { type: "json" }; +import a22 from "./app-definitions/cognee.json" with { type: "json" }; +import a23 from "./app-definitions/jira.json" with { type: "json" }; +import a24 from "./app-definitions/airtable.json" with { type: "json" }; +import a25 from "./app-definitions/beehiiv.json" with { type: "json" }; +import a26 from "./app-definitions/bitly.json" with { type: "json" }; +import a27 from "./app-definitions/candid.json" with { type: "json" }; +import a28 from "./app-definitions/cloudflare.json" with { type: "json" }; +import a29 from "./app-definitions/cloudinary.json" with { type: "json" }; +import a30 from "./app-definitions/coda.json" with { type: "json" }; +import a31 from "./app-definitions/hugging-face.json" with { type: "json" }; +import a32 from "./app-definitions/kernel.json" with { type: "json" }; +import a33 from "./app-definitions/local-falcon.json" with { type: "json" }; +import a34 from "./app-definitions/make.json" with { type: "json" }; +import a35 from "./app-definitions/manufact.json" with { type: "json" }; +import a36 from "./app-definitions/miro.json" with { type: "json" }; +import a37 from "./app-definitions/netlify.json" with { type: "json" }; +import a38 from "./app-definitions/oreilly.json" with { type: "json" }; +import a39 from "./app-definitions/planetscale.json" with { type: "json" }; +import a40 from "./app-definitions/resend.json" with { type: "json" }; +import a41 from "./app-definitions/ticktick.json" with { type: "json" }; +import a42 from "./app-definitions/todoist.json" with { type: "json" }; +import a43 from "./app-definitions/webflow.json" with { type: "json" }; +import a44 from "./app-definitions/wix.json" with { type: "json" }; +import a45 from "./app-definitions/brex.json" with { type: "json" }; +import a46 from "./app-definitions/clickhouse.json" with { type: "json" }; +import a47 from "./app-definitions/egnyte.json" with { type: "json" }; +import a48 from "./app-definitions/embat.json" with { type: "json" }; +import a49 from "./app-definitions/mixpanel.json" with { type: "json" }; +import a50 from "./app-definitions/postman.json" with { type: "json" }; +import a51 from "./app-definitions/razorpay.json" with { type: "json" }; +import a52 from "./app-definitions/sanity.json" with { type: "json" }; +import a53 from "./app-definitions/stripe.json" with { type: "json" }; +import a54 from "./app-definitions/supabase.json" with { type: "json" }; +import a55 from "./app-definitions/ticket-tailor.json" with { type: "json" }; +import a56 from "./app-definitions/asana.json" with { type: "json" }; +import a57 from "./app-definitions/box.json" with { type: "json" }; +import a58 from "./app-definitions/mem0.json" with { type: "json" }; +import a59 from "./app-definitions/pagerduty.json" with { type: "json" }; +import a60 from "./app-definitions/similarweb.json" with { type: "json" }; +import a61 from "./app-definitions/xero.json" with { type: "json" }; +import a62 from "./app-definitions/youcom.json" with { type: "json" }; +import a63 from "./app-definitions/fireflies.json" with { type: "json" }; +import a64 from "./app-definitions/zep.json" with { type: "json" }; +import a65 from "./app-definitions/supermemory.json" with { type: "json" }; +import a66 from "./app-definitions/honcho.json" with { type: "json" }; +import a67 from "./app-definitions/gmail.json" with { type: "json" }; +import a68 from "./app-definitions/google-drive.json" with { type: "json" }; +import a69 from "./app-definitions/google-docs.json" with { type: "json" }; +import a70 from "./app-definitions/google-sheets.json" with { type: "json" }; +import a71 from "./app-definitions/google-slides.json" with { type: "json" }; +import a72 from "./app-definitions/google-calendar.json" with { type: "json" }; +import a73 from "./app-definitions/google-chat.json" with { type: "json" }; +import a74 from "./app-definitions/google-people.json" with { type: "json" }; +import a75 from "./app-definitions/google-workspace-search.json" with { type: "json" }; +import a76 from "./app-definitions/openai.json" with { type: "json" }; +import a77 from "./app-definitions/openrouter.json" with { type: "json" }; +import a78 from "./app-definitions/xai.json" with { type: "json" }; import type { AppDefinition } from "./types/app-definition.js"; -export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74] as AppDefinition[]; +export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78] as AppDefinition[]; diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index 60ff30634d4..5d476da7f67 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -274,7 +274,7 @@ describe("AppDefinition catalog", () => { "google-workspace-search", ]), ); - expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(45); + expect(SELF_SERVE_MCP_CANDIDATES).toHaveLength(48); expect(BLOCKED_MCP_PROVIDERS.map((entry) => entry.slug)).toEqual([ "g2", "vercel", @@ -427,15 +427,15 @@ describe("AppDefinition catalog", () => { expect(channel("slack")?.guidanceMd).toContain("reactions"); expect(channel("slack")?.guidanceMd).toContain("direct messages"); }); - it("keeps a complete, unique, dated evidence ledger for all 48 researched MCP providers", () => { + it("keeps a complete, unique, dated evidence ledger for all 51 researched MCP providers", () => { // Ledger-wide date reflects the last full re-verification (2026-08-26); // later provider additions carry their own research evidence, but // bumping the shared date would overstate freshness for the other providers. expect(SELF_SERVE_MCP_RESEARCH.verifiedAt).toBe("2026-08-26"); - expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(48); + expect(SELF_SERVE_MCP_RESEARCH.entries).toHaveLength(51); expect( new Set(SELF_SERVE_MCP_RESEARCH.entries.map((entry) => entry.slug)), - ).toHaveProperty("size", 48); + ).toHaveProperty("size", 51); for (const entry of SELF_SERVE_MCP_RESEARCH.entries) { expect(new URL(entry.docsUrl).protocol).toBe("https:"); expect(new URL(entry.serverUrl).protocol).toBe("https:"); @@ -719,7 +719,7 @@ describe("AppDefinition catalog", () => { "ticktick", "xero", ]); - expect(APP_STORE_DEFINITIONS).toHaveLength(52); + expect(APP_STORE_DEFINITIONS).toHaveLength(56); const connectableSlugs = new Set( CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug), ); diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts index 1e16e35aa2e..6f510b61fc8 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -6,6 +6,7 @@ import type { ToolConnectionOwnership } from "./types/tool-access.js"; export const CONNECTABLE_APP_SLUGS = new Set([ "anthropic", "openai", "openrouter", "xai", "agentmail", + "cognee", ...SELF_SERVE_MCP_CANDIDATES.map((entry) => entry.slug), "zapier", "arcade", @@ -215,7 +216,8 @@ export function appAcceptsCustomerOAuthClient(app: AppDefinition | null | undefi return Boolean(app && getAvailableConnectionMethods(app).some(connectionMethodAcceptsCustomerOAuthClient)); } -export function credentialConfigPath(field: FieldDef): string { +export function credentialConfigPath(field: FieldDef, method?: ConnectionMethodDef | null): string { + if (method?.transport === "local_stdio" && method.keyPlacement?.location === "env") return `env.${field.key}`; return `credentials.${field.key}`; } diff --git a/packages/shared/src/app-definitions/cognee.json b/packages/shared/src/app-definitions/cognee.json new file mode 100644 index 00000000000..be5473950dc --- /dev/null +++ b/packages/shared/src/app-definitions/cognee.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": 1, + "slug": "cognee", + "name": "Cognee", + "description": "Build and recall shared graph memory from documents and conversations.", + "categories": [ + "ai" + ], + "branding": { + "logoUrl": "/brands/apps/cognee.png" + }, + "urlPatterns": [ + "https://*.aws.cognee.ai/*" + ], + "docsUrl": "https://docs.cognee.ai/cognee-cloud/connections/cloud-mcp", + "setupPrerequisite": { + "title": "Cognee Cloud and a local runtime", + "description": "Use your Cognee Cloud tenant API URL and key. Paperclip's runtime host needs uv installed to run the official Cognee MCP client. Public deployments require a trusted MCP runtime host.", + "actionLabel": "Open Cognee API keys", + "actionUrl": "https://platform.cognee.ai/api-keys" + }, + "methods": [ + { + "key": "cloud-local", + "transport": "local_stdio", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Connect your Cloud tenant through the official Cognee MCP client.", + "defaults": { + "templateKey": "paperclip.cognee-cloud" + }, + "guidanceMd": "Copy the API Base URL and create an API key on Cognee's API Keys page. Use a Cloud workspace with an active subscription. Paperclip uses its bundled Cloud client; no extra runtime installation is required.", + "riskTier": "S3", + "label": "Connect Cognee Cloud", + "credentialFields": [ + { + "key": "COGNEE_BASE_URL", + "label": "API Base URL", + "type": "text", + "required": true, + "secret": false, + "placeholder": "https://your-tenant.aws.cognee.ai", + "validation": { + "pattern": "^https://[a-zA-Z0-9-]+\\.aws\\.cognee\\.ai/?$", + "maxLength": 255 + }, + "helperMd": "Copy API Base URL from Cognee's API Keys page." + }, + { + "key": "COGNEE_API_KEY", + "label": "Cognee API key", + "type": "password", + "required": true, + "placeholder": "Paste your Cognee API key", + "secret": true, + "helperMd": "Create a key in Cognee → API Keys. The key is shown once." + } + ], + "keyPlacement": { + "location": "env", + "name": "COGNEE_API_KEY" + }, + "consoleLinks": { + "keys": "https://platform.cognee.ai/api-keys", + "docs": "https://docs.cognee.ai/cognee-cloud/connections/cloud-mcp" + } + } + ] +} diff --git a/packages/shared/src/app-definitions/honcho.json b/packages/shared/src/app-definitions/honcho.json new file mode 100644 index 00000000000..6dd43ea2a1b --- /dev/null +++ b/packages/shared/src/app-definitions/honcho.json @@ -0,0 +1,56 @@ +{ + "schemaVersion": 1, + "slug": "honcho", + "name": "Honcho", + "description": "Remember conversations and retrieve context about peers.", + "categories": [ + "ai" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/honcho.svg" + }, + "urlPatterns": [ + "https://mcp.honcho.dev/*" + ], + "docsUrl": "https://honcho.dev/docs/v3/guides/integrations/mcp", + "methods": [ + { + "key": "mcp-api-key", + "transport": "mcp_remote", + "auth": "api_key", + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use a restricted customer-owned key when browser sign-in is not suitable.", + "defaults": { + "serverUrl": "https://mcp.honcho.dev" + }, + "guidanceMd": "Open the Honcho dashboard, create an API key for the account agents should use, and paste it below.", + "riskTier": "S3", + "label": "Use an API key", + "credentialFields": [ + { + "key": "authorization", + "label": "Honcho API key", + "type": "password", + "required": true, + "placeholder": "Paste your Honcho API key", + "secret": true + } + ], + "keyPlacement": { + "location": "header", + "name": "Authorization", + "prefix": "Bearer " + }, + "consoleLinks": { + "keys": "https://app.honcho.dev", + "docs": "https://honcho.dev/docs/v3/guides/integrations/mcp" + }, + "warnings": [ + "An API key from the Honcho dashboard. Memory is stored in your Honcho account; select workspace and peer identifiers when calling tools." + ] + } + ] +} diff --git a/packages/shared/src/app-definitions/mem0.json b/packages/shared/src/app-definitions/mem0.json index 27e0558f82f..c78893585d2 100644 --- a/packages/shared/src/app-definitions/mem0.json +++ b/packages/shared/src/app-definitions/mem0.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "slug": "mem0", "name": "Mem0", - "description": "Connect Mem0's provider-hosted MCP server.", + "description": "Remember preferences, conversations, events, and agent state.", "categories": [ "ai" ], @@ -26,7 +26,7 @@ "defaults": { "serverUrl": "https://mcp.mem0.ai/mcp/" }, - "guidanceMd": "Use a customer-created Mem0 key. A Mem0 API key; the live server currently requires the slash-normalized endpoint.", + "guidanceMd": "Open the Mem0 dashboard, create an API key for the account agents should use, and paste it below.", "riskTier": "S3", "label": "Use an API key", "credentialFields": [ @@ -35,7 +35,7 @@ "label": "Mem0 API key", "type": "password", "required": true, - "placeholder": "m0sk_...", + "placeholder": "Paste your Mem0 API key", "secret": true } ], @@ -45,7 +45,7 @@ "prefix": "Bearer " }, "consoleLinks": { - "keys": "https://docs.mem0.ai/platform/mem0-mcp", + "keys": "https://app.mem0.ai/dashboard/api-keys", "docs": "https://docs.mem0.ai/platform/mem0-mcp" }, "warnings": [ diff --git a/packages/shared/src/app-definitions/supermemory.json b/packages/shared/src/app-definitions/supermemory.json new file mode 100644 index 00000000000..0a8e0356464 --- /dev/null +++ b/packages/shared/src/app-definitions/supermemory.json @@ -0,0 +1,50 @@ +{ + "schemaVersion": 1, + "slug": "supermemory", + "name": "Supermemory", + "description": "Search and save shared memories, documents, and profiles.", + "categories": [ + "ai" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/supermemory.png" + }, + "urlPatterns": [ + "https://mcp.supermemory.ai/*" + ], + "docsUrl": "https://supermemory.ai/docs/supermemory-mcp/mcp", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", + "defaults": { + "serverUrl": "https://mcp.supermemory.ai/mcp", + "scopesHint": [ + "openid", + "profile", + "email", + "offline_access" + ] + }, + "guidanceMd": "Sign in to Supermemory, then choose a workspace, read or write access, and optional tags. Use a separate space for unrelated work.", + "riskTier": "S3", + "label": "Sign in with Supermemory", + "consoleLinks": { + "docs": "https://supermemory.ai/docs/supermemory-mcp/mcp" + }, + "warnings": [ + "Sign in to Supermemory and select the spaces this connection may access. The hosted MCP uses OAuth, not a developer API key." + ], + "grantKinds": [ + "user" + ] + } + ] +} diff --git a/packages/shared/src/app-definitions/zep.json b/packages/shared/src/app-definitions/zep.json new file mode 100644 index 00000000000..5a2c8875d41 --- /dev/null +++ b/packages/shared/src/app-definitions/zep.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 1, + "slug": "zep", + "name": "Zep", + "description": "Retrieve temporal graph memory and authorized business context.", + "categories": [ + "ai" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/zep.png" + }, + "urlPatterns": [ + "https://api.getzep.com/*" + ], + "docsUrl": "https://help.getzep.com/memory-mcp-server", + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr" + ], + "whenToUse": "Use browser sign-in for the provider-hosted MCP server.", + "defaults": { + "serverUrl": "https://api.getzep.com/mcp", + "scopesHint": [ + "graph:read", + "graph:write" + ] + }, + "guidanceMd": "Sign in with the work identity configured for your Zep project's Memory MCP server. Zep restricts access to that identity's memory and authorized shared graphs.", + "riskTier": "S3", + "label": "Sign in with Zep", + "consoleLinks": { + "settings": "https://app.getzep.com", + "docs": "https://help.getzep.com/memory-mcp-server" + }, + "warnings": [ + "A Zep project with Memory MCP enabled, available MCP seats, and Google Workspace or enterprise OIDC configured by its administrator. Project API keys do not authenticate Memory MCP." + ], + "grantKinds": [ + "user" + ] + } + ] +} diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index fcbd2242e63..d914fe0ce80 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -116,6 +116,13 @@ export const INSTANCE_FEATURE_CATALOG: Record { + it("defaults off and preserves patch semantics", () => { + expect(instanceExperimentalSettingsSchema.parse({}).enableMemoryConnectors).toBe(false); + expect(patchInstanceExperimentalSettingsSchema.parse({})).not.toHaveProperty("enableMemoryConnectors"); + expect(patchInstanceExperimentalSettingsSchema.parse({ enableMemoryConnectors: true })).toEqual({ enableMemoryConnectors: true }); + expect(INSTANCE_FEATURE_CATALOG.enableMemoryConnectors).toMatchObject({ tier: "managed", selfHostedDefault: false, cloudDefault: false }); + expect(isMemoryConnectorId("notion")).toBe(false); + }); + it.each(MEMORY_CONNECTOR_IDS)("has a supported, branded %s setup method", (slug) => { + const app = getConnectableAppDefinition(slug)!; + expect(app).toBeDefined(); + expect(app.branding.logoUrl).toMatch(new RegExp(`/brands/apps/${slug}\\.`)); + expect(app.methods).toHaveLength(1); + expect(app.methods[0]!.transport).toBe(slug === "cognee" ? "local_stdio" : "mcp_remote"); + }); + it("uses provider-documented auth and endpoint paths", () => { + const method = (slug: string) => getConnectableAppDefinition(slug)!.methods[0]!; + expect(method("mem0")).toMatchObject({ auth: "api_key", defaults: { serverUrl: "https://mcp.mem0.ai/mcp/" }, keyPlacement: { location: "header", name: "Authorization", prefix: "Bearer " } }); + expect(method("honcho")).toMatchObject({ auth: "api_key", defaults: { serverUrl: "https://mcp.honcho.dev" }, keyPlacement: { location: "header", name: "Authorization", prefix: "Bearer " } }); + expect(method("zep")).toMatchObject({ auth: "oauth", grantKinds: ["user"], ownershipModes: ["dcr"], defaults: { serverUrl: "https://api.getzep.com/mcp", scopesHint: ["graph:read", "graph:write"] } }); + expect(method("supermemory")).toMatchObject({ auth: "oauth", grantKinds: ["user"], defaults: { serverUrl: "https://mcp.supermemory.ai/mcp" } }); + const cognee = method("cognee"); + expect(cognee.defaults).toEqual({ templateKey: "paperclip.cognee-cloud" }); + expect(cognee.credentialFields!.map(field => credentialConfigPath(field, cognee))).toEqual(["env.COGNEE_BASE_URL", "env.COGNEE_API_KEY"]); + expect(credentialConfigPath(method("mem0").credentialFields![0]!, method("mem0"))).toBe("credentials.authorization"); + }); +}); diff --git a/packages/shared/src/memory-connectors.ts b/packages/shared/src/memory-connectors.ts new file mode 100644 index 00000000000..c224910a12b --- /dev/null +++ b/packages/shared/src/memory-connectors.ts @@ -0,0 +1,6 @@ +/** Setup rollout only: hiding the catalog does not revoke existing connections. */ +export const MEMORY_CONNECTOR_IDS = ["mem0", "zep", "supermemory", "cognee", "honcho"] as const; +export type MemoryConnectorId = (typeof MEMORY_CONNECTOR_IDS)[number]; +export function isMemoryConnectorId(value: unknown): value is MemoryConnectorId { + return typeof value === "string" && (MEMORY_CONNECTOR_IDS as readonly string[]).includes(value); +} diff --git a/packages/shared/src/self-serve-mcp-research.json b/packages/shared/src/self-serve-mcp-research.json index 7a9b5f2c5b9..0efcf21e2c4 100644 --- a/packages/shared/src/self-serve-mcp-research.json +++ b/packages/shared/src/self-serve-mcp-research.json @@ -53,6 +53,9 @@ {"slug": "fireflies", "name": "Fireflies", "wave": 4, "status": "self_serve", "docsUrl": "https://docs.fireflies.ai/getting-started/mcp-configuration", "serverUrl": "https://api.fireflies.ai/mcp", "authMode": "dcr_or_api_key", "prerequisite": "A Fireflies account with access to the meetings you want to use. API keys are available in Settings → Developer Settings.", "riskTier": "S3"}, { "slug": "g2", "name": "G2", "wave": "blocked", "status": "blocked", "docsUrl": "https://documentation.g2.com/docs/g2-mcp-server", "serverUrl": "https://mcp.g2.com/mcp", "authMode": "provider_approval", "prerequisite": "G2 must enable cross-application token introspection before an independently registered client can work.", "riskTier": "S3" }, { "slug": "vercel", "name": "Vercel", "wave": "blocked", "status": "blocked", "docsUrl": "https://vercel.com/docs/agent-resources/vercel-mcp", "serverUrl": "https://mcp.vercel.com", "authMode": "provider_approval", "prerequisite": "Vercel currently reviews and approves MCP clients.", "riskTier": "S3" }, - { "slug": "zomato", "name": "Zomato", "wave": "blocked", "status": "blocked", "docsUrl": "https://github.com/Zomato/mcp-server-manifest", "serverUrl": "https://mcp-server.zomato.com/mcp", "authMode": "provider_approval", "prerequisite": "Zomato currently limits third-party clients and requires redirect-URI allowlisting.", "riskTier": "S3" } + { "slug": "zomato", "name": "Zomato", "wave": "blocked", "status": "blocked", "docsUrl": "https://github.com/Zomato/mcp-server-manifest", "serverUrl": "https://mcp-server.zomato.com/mcp", "authMode": "provider_approval", "prerequisite": "Zomato currently limits third-party clients and requires redirect-URI allowlisting.", "riskTier": "S3" }, + {"slug": "zep", "name": "Zep", "wave": 3, "status": "self_serve", "docsUrl": "https://help.getzep.com/memory-mcp-server", "serverUrl": "https://api.getzep.com/mcp", "authMode": "dcr_cimd", "prerequisite": "A Zep project with Memory MCP enabled, available MCP seats, and Google Workspace or enterprise OIDC configured by its administrator. Project API keys do not authenticate Memory MCP.", "riskTier": "S3"}, + {"slug": "supermemory", "name": "Supermemory", "wave": 3, "status": "self_serve", "docsUrl": "https://supermemory.ai/docs/supermemory-mcp/mcp", "serverUrl": "https://mcp.supermemory.ai/mcp", "authMode": "dcr", "prerequisite": "Sign in to Supermemory and select the spaces this connection may access. The hosted MCP uses OAuth, not a developer API key.", "riskTier": "S3"}, + {"slug": "honcho", "name": "Honcho", "wave": 3, "status": "self_serve", "docsUrl": "https://honcho.dev/docs/v3/guides/integrations/mcp", "serverUrl": "https://mcp.honcho.dev", "authMode": "api_key", "prerequisite": "An API key from the Honcho dashboard. Memory is stored in your Honcho account; select workspace and peer identifiers when calling tools.", "riskTier": "S3"} ] } diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 5ed400e3b25..5c32253a99b 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -73,6 +73,8 @@ export interface InstanceExperimentalSettings { enableChatConnectors: boolean; /** Exposes MCP aggregator setup; existing connections keep running when hidden. */ enableMcpAggregators: boolean; + /** Show experimental memory connection setup. Existing connections remain usable. */ + enableMemoryConnectors: boolean; enablePipelines: boolean; enableCases: boolean; enableAgentChat: boolean; diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 136386708a7..6131a487ca0 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -53,6 +53,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableApps: z.boolean().default(true), enableChatConnectors: z.boolean().default(false), enableMcpAggregators: z.boolean().default(false), + enableMemoryConnectors: z.boolean().default(false), enablePipelines: z.boolean().default(false), enableCases: z.boolean().default(false), enableAgentChat: z.boolean().default(false), diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index 4285f64431b..6c4de3a271f 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -920,6 +920,9 @@ const categoryBySlug = { make: "productivity", manufact: "productivity", mem0: "ai", + zep: "ai", + supermemory: "ai", + honcho: "ai", miro: "productivity", mixpanel: "analytics", netlify: "developer", @@ -995,7 +998,7 @@ const apiKeySpec = { prefix: null, placeholder: "Paste your Kernel API key", }, - mem0: { name: "Authorization", prefix: "Bearer ", placeholder: "m0sk_..." }, + mem0: { name: "Authorization", prefix: "Bearer ", placeholder: "Paste your Mem0 API key" }, oreilly: { name: "Authorization", prefix: "Bearer ", @@ -1073,6 +1076,23 @@ const apiKeyMethodFor = ( ); }; const specialMethodsFor = (entry) => { + if (entry.slug === "mem0" || entry.slug === "honcho") return [ + apiKeyMethodFor(entry, "mcp-api-key", entry.serverUrl, { + guidanceMd: `Open the ${entry.name} dashboard, create an API key for the account agents should use, and paste it below.`, + consoleLinks: { keys: entry.slug === "mem0" ? "https://app.mem0.ai/dashboard/api-keys" : "https://app.honcho.dev", docs: entry.docsUrl }, + }), + ]; + if (entry.slug === "zep") return [oauthMethodFor(entry, "mcp-oauth", entry.serverUrl, { + grantKinds: ["user"], + defaults: { serverUrl: entry.serverUrl, scopesHint: ["graph:read", "graph:write"] }, + guidanceMd: "Sign in with the work identity configured for your Zep project's Memory MCP server. Zep restricts access to that identity's memory and authorized shared graphs.", + consoleLinks: { settings: "https://app.getzep.com", docs: entry.docsUrl }, + })]; + if (entry.slug === "supermemory") return [oauthMethodFor(entry, "mcp-oauth", entry.serverUrl, { + grantKinds: ["user"], + defaults: { serverUrl: entry.serverUrl, scopesHint: ["openid", "profile", "email", "offline_access"] }, + guidanceMd: "Sign in to Supermemory, then choose a workspace, read or write access, and optional tags. Use a separate space for unrelated work.", + })]; if (entry.slug === "fireflies") return [ oauthMethodFor(entry, "mcp-oauth", entry.serverUrl, { @@ -1379,6 +1399,31 @@ const specialMethodsFor = (entry) => { return null; }; +// Cognee Cloud publishes a local MCP client, not a hosted remote MCP endpoint. +// This approved, pinned template uses the ordinary vault and stdio gateway. +apps.push({ + schemaVersion: 1, slug: "cognee", name: "Cognee", + description: "Build and recall shared graph memory from documents and conversations.", + categories: ["ai"], branding: brandingFor("cognee"), + urlPatterns: ["https://*.aws.cognee.ai/*"], + docsUrl: "https://docs.cognee.ai/cognee-cloud/connections/cloud-mcp", + setupPrerequisite: { + title: "Cognee Cloud and a local runtime", + description: "Use your Cognee Cloud tenant API URL and key. Paperclip's runtime host needs uv installed to run the official Cognee MCP client. Public deployments require a trusted MCP runtime host.", + actionLabel: "Open Cognee API keys", actionUrl: "https://platform.cognee.ai/api-keys", + }, + methods: [method("cloud-local", "local_stdio", "api_key", { templateKey: "paperclip.cognee-cloud" }, "S3", + "Copy the API Base URL and create an API key on Cognee's API Keys page. Use a Cloud workspace with an active subscription. Paperclip uses its bundled Cloud client; no extra runtime installation is required.", { + label: "Connect Cognee Cloud", whenToUse: "Connect your Cloud tenant through the official Cognee MCP client.", + credentialFields: [ + { key: "COGNEE_BASE_URL", label: "API Base URL", type: "text", required: true, secret: false, placeholder: "https://your-tenant.aws.cognee.ai", validation: { pattern: "^https://[a-zA-Z0-9-]+\\.aws\\.cognee\\.ai/?$", maxLength: 255 }, helperMd: "Copy API Base URL from Cognee's API Keys page." }, + { ...field("COGNEE_API_KEY", "Cognee API key", "Paste your Cognee API key"), helperMd: "Create a key in Cognee → API Keys. The key is shown once." }, + ], + keyPlacement: { location: "env", name: "COGNEE_API_KEY" }, + consoleLinks: { keys: "https://platform.cognee.ai/api-keys", docs: "https://docs.cognee.ai/cognee-cloud/connections/cloud-mcp" }, + })], +}); + for (const entry of researchManifest.entries) { const existing = apps.find((app) => app.slug === entry.slug); if (entry.status === "blocked") { @@ -1426,9 +1471,9 @@ for (const entry of researchManifest.entries) { schemaVersion: 1, slug: entry.slug, name: entry.name, - description: entry.slug === "fireflies" + description: ({ mem0: "Remember preferences, conversations, events, and agent state.", zep: "Retrieve temporal graph memory and authorized business context.", supermemory: "Search and save shared memories, documents, and profiles.", honcho: "Remember conversations and retrieve context about peers." })[entry.slug] ?? (entry.slug === "fireflies" ? "Search meeting transcripts, read summaries and action items, and connect meeting-ready routines." - : `Connect ${entry.name}'s provider-hosted MCP server.`, + : `Connect ${entry.name}'s provider-hosted MCP server.`), categories: [categoryBySlug[entry.slug] ?? "other"], featured: entry.slug === "jira", branding: brandingFor(entry.slug), diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index f5a3cfab064..98157ca653e 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -44,6 +44,7 @@ describe("instance settings service", () => { enableAgentChat: false, enableChatConnectors: false, enableMcpAggregators: false, + enableMemoryConnectors: false, enableConferenceRoomChat: false, enableClassicTaskInterface: false, enableExternalObjects: false, diff --git a/server/src/__tests__/memory-connectors.test.ts b/server/src/__tests__/memory-connectors.test.ts new file mode 100644 index 00000000000..e54102896f1 --- /dev/null +++ b/server/src/__tests__/memory-connectors.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { classifyRisk } from "../services/tool-access.js"; +import { COGNEE_STDIO_TEMPLATE, cogneeCloudUrl, callCogneeCloud } from "../services/cognee-connection.js"; + +describe("memory tool governance", () => { + it.each([ + ["mem0", "add_memory", "write"], ["mem0", "search_memories", "read"], + ["mem0", "delete_entities", "destructive"], ["zep", "add_memory_to_graph", "write"], + ["supermemory", "add_memory", "destructive"], ["supermemory", "select-space", "write"], + ["supermemory", "get_profile", "read"], ["cognee", "remember", "write"], + ["cognee", "recall", "read"], ["cognee", "forget", "destructive"], + ["honcho", "create_peer", "write"], ["honcho", "future_unknown_action", "write"], + ])("classifies %s %s even with a misleading read hint", (provider, name, expected) => { + expect(classifyRisk({ name, annotations: { readOnlyHint: true } }, provider)).toBe(expected); + }); + it("keeps the approved Cognee bridge free of runtime package resolution", () => { + expect(COGNEE_STDIO_TEMPLATE.command).toBeNull(); + expect(COGNEE_STDIO_TEMPLATE.args).toEqual([]); + expect(COGNEE_STDIO_TEMPLATE.envKeys).toEqual(["COGNEE_BASE_URL", "COGNEE_API_KEY"]); + expect(COGNEE_STDIO_TEMPLATE.tools.map(t => t.name)).toEqual(["remember", "recall", "forget"]); + }); + it.each(["http://localhost", "https://evil.test", "https://tenant.aws.cognee.ai@evil.test", "https://tenant.aws.cognee.ai/other", "https://tenant.aws.cognee.ai?key=secret", "https://tenant.aws.cognee.ai:8443"])("rejects unreviewed Cognee URL %s", (url) => { + expect(() => cogneeCloudUrl(url)).toThrow(); + }); + it("accepts the tenant API origin", () => expect(cogneeCloudUrl("https://tenant.aws.cognee.ai").origin).toBe("https://tenant.aws.cognee.ai")); + it("writes text as a file to the tenant-only Cloud endpoint", async () => { + const request = vi.fn().mockResolvedValue({ status: "completed" }); + const result = await callCogneeCloud({ baseUrl: "https://fixture.aws.cognee.ai", apiKey: "fixture-key", + tool: "remember", parameters: { data: "synthetic memory", dataset_name: "fixture" }, request, + signal: AbortSignal.timeout(1000) }); + const [url, init] = request.mock.calls[0]!; + expect(url.href).toBe("https://fixture.aws.cognee.ai/api/v1/remember"); + expect(init).toMatchObject({ method: "POST", redirect: "manual", headers: expect.objectContaining({ "x-api-key": "fixture-key" }) }); + expect(Buffer.isBuffer(init.body)).toBe(true); + const form = await new Response(init.body, { headers: init.headers }).formData(); + expect(form.get("datasetName")).toBe("fixture"); + expect(await (form.get("data") as File).text()).toBe("synthetic memory"); + expect(result).toMatchObject({ isError: false, structuredContent: { result: { status: "completed" } } }); + }); + it("keeps session writes on the Cloud session endpoint", async () => { + const request = vi.fn().mockResolvedValue({ status: "completed" }); + await callCogneeCloud({ baseUrl: "https://fixture.aws.cognee.ai", apiKey: "key", tool: "remember", + parameters: { data: "text", session_id: "session", dataset_name: "fixture" }, request, signal: AbortSignal.timeout(1000) }); + expect(request.mock.calls[0]![0].pathname).toBe("/api/v1/remember/entry"); + expect(JSON.parse(request.mock.calls[0]![1].body)).toMatchObject({ + entry: { type: "qa", answer: "text" }, session_id: "session", dataset_name: "fixture" }); + }); + it("preserves scoped recall arguments and does not fetch other datasets", async () => { + const request = vi.fn().mockResolvedValue(["memory"]); + await callCogneeCloud({ baseUrl: "https://fixture.aws.cognee.ai", apiKey: "key", tool: "recall", + parameters: { query: "find", datasets: "one, two", search_type: "chunks", top_k: 3 }, request, signal: AbortSignal.timeout(1000) }); + expect(request).toHaveBeenCalledTimes(1); + expect(request.mock.calls[0]![0].pathname).toBe("/api/v1/recall"); + expect(JSON.parse(request.mock.calls[0]![1].body)).toEqual({ query: "find", datasets: ["one", "two"], search_type: "CHUNKS", top_k: 3 }); + }); + it("matches the official client's unscoped dataset discovery", async () => { + const request = vi.fn().mockResolvedValueOnce([{ name: "one" }]).mockResolvedValueOnce(["memory"]); + await callCogneeCloud({ baseUrl: "https://fixture.aws.cognee.ai", apiKey: "key", tool: "recall", + parameters: { query: "find" }, request, signal: AbortSignal.timeout(1000) }); + expect(request.mock.calls[0]![0].pathname).toBe("/api/v1/datasets/"); + expect(JSON.parse(request.mock.calls[1]![1].body).datasets).toEqual(["one"]); + }); + it("requires an explicit delete target and never falls back after a provider failure", async () => { + const request = vi.fn().mockRejectedValue(new Error("Cloud unavailable")); + const input = { baseUrl: "https://fixture.aws.cognee.ai", apiKey: "key", tool: "forget", parameters: {}, request, signal: AbortSignal.timeout(1000) }; + await expect(callCogneeCloud(input)).rejects.toThrow("Specify a dataset"); + expect(request).not.toHaveBeenCalled(); + await expect(callCogneeCloud({ ...input, parameters: { dataset: "fixture" } })).rejects.toThrow("Cloud unavailable"); + expect(request).toHaveBeenCalledTimes(1); + expect(JSON.parse(request.mock.calls[0]![1].body)).toEqual({ dataset: "fixture", everything: false }); + }); +}); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 7155adbec08..f6bbc6c92b6 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 }); + await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true, enableMemoryConnectors: true }); return; } tempDb = await startEmbeddedPostgresTestDatabase( "paperclip-tool-access-service-", ); db = createDb(tempDb.connectionString); - await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true }); + await instanceSettingsService(db).updateExperimental({ enableMcpAggregators: true, enableMemoryConnectors: true }); }, 20_000); afterEach(async () => { @@ -5092,7 +5092,7 @@ describeEmbeddedPostgres("tool access service", () => { "youcom", ]), ); - expect(res.body.apps).toHaveLength(52); + expect(res.body.apps).toHaveLength(56); expect( res.body.apps.find((app: { slug: string }) => app.slug === "gmail") .ownershipAvailability, @@ -11589,6 +11589,149 @@ describeEmbeddedPostgres("tool access service", () => { ); }); + it.each(["mem0", "zep", "supermemory", "cognee", "honcho"])("rejects %s setup before creating credentials when memory connectors are disabled", async (provider) => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: false }); + try { + const response = await request(createRouteApp(db)).get(`/api/companies/${company.id}/tools/gallery`).expect(200); + expect(response.body.apps.some((app: { slug: string }) => app.slug === provider)).toBe(false); + await expect(service.connectGalleryApp(company.id, { galleryKey: provider })).rejects.toMatchObject({ status: 403, details: { code: "memory_connectors_disabled" } }); + expect(await db.select().from(toolConnections).where(eq(toolConnections.companyId, company.id))).toHaveLength(0); + expect(await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id))).toHaveLength(0); + } finally { + await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: true }); + } + }); + + it("rotates an existing Mem0 key while new memory setup is disabled", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + mockToolsList([{ name: "search_memories", annotations: { readOnlyHint: true } }]); + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "mem0", credentialValues: { "credentials.authorization": "old-key" }, + }); + await db.update(toolConnections).set({ status: "active" }).where(eq(toolConnections.id, connected.connectionId)); + await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: false }); + try { + const result = await service.reconnectGalleryApp(connected.connectionId, company.id, + { credentialValues: { "credentials.authorization": "new-key" } }); + expect(result.connection.id).toBe(connected.connectionId); + expect(result.connection.healthStatus).toBe("ok"); + } finally { + await instanceSettingsService(db).updateExperimental({ enableMemoryConnectors: true }); + } + }); + + it("keeps active Cognee tools available during a transient Cloud probe outage", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("[]", { status: 200 })); + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "cognee", credentialValues: { "env.COGNEE_BASE_URL": "https://fixture.aws.cognee.ai", "env.COGNEE_API_KEY": "key" }, + }); + await db.update(toolConnections).set({ status: "active" }).where(eq(toolConnections.id, connected.connectionId)); + fetchMock.mockReset().mockRejectedValue(new Error("temporary timeout")); + const result = await service.checkHealth(connected.connectionId); + expect(result.connection.healthStatus).toBe("ok"); + expect(fetchMock).not.toHaveBeenCalled(); + const catalog = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connected.connectionId)); + expect(catalog).toHaveLength(3); + }); + + it("creates a user-owned value when reconnect restores a missing personal credential field", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + mockToolsList([{ name: "search_memories", annotations: { readOnlyHint: true } }]); + const actor = { actorType: "user" as const, actorId: "personal-reconnect-owner" }; + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "mem0", grantKind: "user", credentialValues: { "credentials.authorization": "old-key" }, + }, actor); + const { grants } = await service.listConnectionGrants(connected.connectionId, company.id); + await db.update(connectionGrants).set({ credentialSecretRefs: [] }).where(eq(connectionGrants.id, grants[0]!.id)); + await service.reconnectGalleryApp(connected.connectionId, company.id, + { credentialValues: { "credentials.authorization": "restored-key" } }, actor); + const after = await service.listConnectionGrants(connected.connectionId, company.id); + const ref = after.grants[0]!.credentialSecretRefs[0]!; + const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.id, ref.secretId)); + expect(secret).toMatchObject({ scope: "user", ownerUserId: actor.actorId }); + const resolved = await secretService(db).resolveUserSecretValue(company.id, { + definitionId: secret.userSecretDefinitionId!, responsibleUserId: actor.actorId, + }, { consumerType: "tool_connection", consumerId: connected.connectionId, configPath: ref.configPath, + actorType: "system", actorId: null, responsibleUserId: actor.actorId }); + expect(resolved?.value).toBe("restored-key"); + expect((await service.getConnection(connected.connectionId, company.id)).credentialSecretRefs).toEqual([]); + }); + + it("keeps rejected Mem0 API keys on the key-entry path rather than switching to OAuth", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("unauthorized", { status: 401, headers: { "www-authenticate": "Bearer" } })); + await expect(service.connectGalleryApp(company.id, { + galleryKey: "mem0", credentialValues: { "credentials.authorization": "invalid-key" }, + })).rejects.toMatchObject({ status: 422, details: { code: "memory_api_key_rejected" } }); + expect(await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id))).toHaveLength(0); + }); + + it("vaults Cognee environment credentials and verifies Cloud access before exposing its reviewed tools", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("[]", { status: 200 })); + const key = "cognee-private-fixture-key"; + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "cognee", + credentialValues: { "env.COGNEE_BASE_URL": "https://fixture.aws.cognee.ai", "env.COGNEE_API_KEY": key }, + }); + expect(fetchMock).toHaveBeenCalledWith("https://fixture.aws.cognee.ai/api/v1/datasets/", expect.objectContaining({ + method: "GET", headers: { "X-Api-Key": key }, redirect: "manual", + })); + const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId)); + expect(connection.config).toMatchObject({ templateId: "paperclip.cognee-cloud" }); + expect(connection.credentialSecretRefs.map(ref => ref.configPath).sort()).toEqual(["env.COGNEE_API_KEY", "env.COGNEE_BASE_URL"]); + expect(JSON.stringify(connected)).not.toContain(key); + expect(JSON.stringify(connection)).not.toContain(key); + const catalog = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connected.connectionId)); + expect(catalog.map(entry => [entry.toolName, entry.riskLevel]).sort()).toEqual([["forget", "destructive"], ["recall", "read"], ["remember", "write"]]); + }); + + it("resolves personal Cognee credentials through their owner and durable declarations", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("[]", { status: 200 })); + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "cognee", grantKind: "user", + credentialValues: { "env.COGNEE_BASE_URL": "https://fixture.aws.cognee.ai", "env.COGNEE_API_KEY": "personal-cognee-key" }, + }, { actorType: "user", actorId: "carol" }); + const { grants } = await service.listConnectionGrants(connected.connectionId, company.id); + expect(grants).toHaveLength(1); + expect(connected.connection.credentialSecretRefs).toEqual([]); + const vault = secretService(db); + for (const ref of grants[0]!.credentialSecretRefs) { + const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.id, ref.secretId)); + expect(secret).toMatchObject({ scope: "user", ownerUserId: "carol" }); + const resolved = await vault.resolveUserSecretValue(company.id, { + definitionId: secret.userSecretDefinitionId, responsibleUserId: "carol", + }, { consumerType: "tool_connection", consumerId: connected.connectionId, configPath: ref.configPath, + actorType: "system", actorId: null, responsibleUserId: "carol" }); + expect(resolved?.value).toBe(ref.configPath === "env.COGNEE_API_KEY" ? "personal-cognee-key" : "https://fixture.aws.cognee.ai"); + await expect(vault.resolveUserSecretValue(company.id, { + definitionId: secret.userSecretDefinitionId, responsibleUserId: "another-user", + })).rejects.toMatchObject({ status: 422, details: { code: "user_secret_missing" } }); + } + }); + + it.each(["organization", "user"] as const)("cleans up a rejected %s Cognee connection and its vault records", async (grantKind) => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("unauthorized", { status: 401 })); + await expect(service.connectGalleryApp(company.id, { + galleryKey: "cognee", grantKind, credentialValues: { "env.COGNEE_BASE_URL": "https://fixture.aws.cognee.ai", "env.COGNEE_API_KEY": "invalid-key" }, + }, { actorType: "user", actorId: "carol" })).rejects.toMatchObject({ status: 422, details: { code: "cognee_access_unverified" } }); + expect(await db.select().from(userSecretDefinitions).where(eq(userSecretDefinitions.companyId, company.id))).toHaveLength(0); + expect(await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.companyId, company.id))).toHaveLength(0); + expect(await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id))).toHaveLength(0); + }); + it("retains an encrypted API key when the same draft method resumes", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); diff --git a/server/src/__tests__/tool-gateway-service.test.ts b/server/src/__tests__/tool-gateway-service.test.ts index 47785d69c44..2f0774be90c 100644 --- a/server/src/__tests__/tool-gateway-service.test.ts +++ b/server/src/__tests__/tool-gateway-service.test.ts @@ -1439,16 +1439,14 @@ describeEmbeddedPostgres("tool gateway service", () => { const tool = (await gateway.listToolsForSession(session.token)) .find((candidate) => candidate.providerType === "mcp_remote_http"); - const result = await gateway.executeTool({ + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool!.name, parameters: {}, + })).rejects.toMatchObject({ + reasonCode: "tool_error", + message: expect.stringContaining("enroll the signed-in Workspace account and this OAuth client's Google Cloud project"), }); - - expect(result.status).toBe("completed"); - expect((result.result as { content?: string }).content).toContain( - "enroll the signed-in Workspace account and this OAuth client's Google Cloud project", - ); }); it("injects Vercel tokens at dispatch and refreshes exactly once after an upstream 401", async () => { diff --git a/server/src/__tests__/tool-gateway.test.ts b/server/src/__tests__/tool-gateway.test.ts index 6c0ec669080..557a1622859 100644 --- a/server/src/__tests__/tool-gateway.test.ts +++ b/server/src/__tests__/tool-gateway.test.ts @@ -54,6 +54,7 @@ import { } from "../services/tool-content-guards.js"; import { createToolGatewayService, ToolGatewayHttpError } from "../services/tool-gateway.js"; import { secretService } from "../services/secrets.js"; +import * as cogneeBridge from "../services/cognee-connection.js"; import { createKvDemoHttpServer, type KvDemoHttpServer } from "../../../packages/kv-demo-mcp-server/src/http.js"; import { getEmbeddedPostgresTestSupport, @@ -582,6 +583,72 @@ describeEmbeddedPostgres("tool gateway acceptance", () => { await tempDb?.cleanup(); }); + it("preserves provider tool errors in MCP responses and failed invocation audits", async () => { + const company = await createCompany(db); + const remote = await startFakeRemoteMcpServer(({ body }) => ({ + body: { + jsonrpc: "2.0", + id: body?.id, + result: { content: [{ type: "text", text: "Search denied: outside consented tag" }], isError: true }, + }, + })); + try { + const { application, connection, catalogEntry } = await createRemoteMcpTool(db, company.id, { + url: remote.url, toolName: "search_memory", riskLevel: "read", + }); + await db.update(toolCatalogEntries).set({ + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }).where(eq(toolCatalogEntries.id, catalogEntry.id)); + const toolName = expectedConnectedToolName({ + applicationKey: application.applicationKey, + connectionId: connection.id, + toolName: catalogEntry.toolName, + }); + const [profile] = await db.insert(toolProfiles).values({ + companyId: company.id, profileKey: `error-${randomUUID()}`, + name: "Provider error test", defaultAction: "deny", + }).returning(); + await db.insert(toolProfileEntries).values({ + companyId: company.id, profileId: profile.id, + selectorType: "tool_name", effect: "include", toolName, + }); + const gateway = createTestToolGatewayService(db); + const named = await gateway.createNamedGateway({ + companyId: company.id, body: { name: "Provider error test", profileId: profile.id }, + }); + const token = await gateway.createNamedGatewayToken({ + companyId: company.id, gatewayId: named.id, body: { name: "Test" }, + }); + const response = await request(createGatewayRouteApp(db, gateway)) + .post(named.endpointPath) + .set("authorization", `Bearer ${token.token}`) + .send({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, arguments: {} } }) + .expect(200); + expect(response.body.result).toEqual({ + content: [{ type: "text", text: "Search denied: outside consented tag" }], isError: true, + }); + expect(await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, company.id))) + .toEqual([expect.objectContaining({ status: "failed", errorCode: "tool_error" })]); + const events = await db.select().from(toolCallEvents).where(eq(toolCallEvents.companyId, company.id)); + expect(events).toContainEqual(expect.objectContaining({ eventType: "call_failed", outcome: "failure", reasonCode: "tool_error" })); + expect(events.some((event) => event.eventType === "call_completed")).toBe(false); + const agent = await createAgent(db, company.id); + await allowAllToolsForAgent(db, company.id, agent.id); + await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", + principalId: "test-user", status: "active", membershipRole: "member" }); + const testCall = await gateway.executeTestCall({ companyId: company.id, connectionId: connection.id, + agentId: agent.id, userId: "test-user", toolName, parameters: {} }); + expect(testCall).toMatchObject({ decision: "allowed", error: { reasonCode: "tool_error" } }); + expect(await db.select().from(toolInvocations).where(eq(toolInvocations.id, testCall.invocationId))) + .toEqual([expect.objectContaining({ status: "failed", errorCode: "tool_error" })]); + const testEvents = await db.select().from(toolCallEvents).where(eq(toolCallEvents.invocationId, testCall.invocationId)); + expect(testEvents.some(event => event.eventType === "call_failed")).toBe(true); + expect(testEvents.some(event => event.eventType === "call_completed")).toBe(false); + } finally { + await remote.close(); + } + }); + it("exposes a named gateway with scoped bearer-token auth and revocation", async () => { const company = await createCompany(db); const remote = await startFakeRemoteMcpServer(async ({ body }) => ({ @@ -1659,6 +1726,54 @@ rl.on("line", (line) => { } }); + it.each(["credentials.authorization", "headers.X-Api-Key", "oauth.access_token"])( + "resolves a personal remote credential using its declared %s path", + async (configPath) => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const { run } = await createIssueAndRun(db, company.id, agent.id); + await createActiveMember(db, company.id, "alice"); + await db.update(heartbeatRuns).set({ responsibleUserId: "alice" }).where(eq(heartbeatRuns.id, run.id)); + const secrets = secretService(db); + const definition = await secrets.createUserSecretDefinition(company.id, { + name: "Personal remote token", + key: `personal_remote_${randomUUID().replace(/-/g, "")}`, + provider: "local_encrypted", + }); + const secret = await secrets.createCurrentUserSecretValue(company.id, "alice", { + definitionId: definition.id, value: "personal-remote-test-token", + }); + const remote = await createRemoteMcpTool(db, company.id); + await db.update(toolConnections).set({ + credentialPolicy: "per_user", + credentialRefs: [{ name: configPath, secretId: secret.id, placement: "header", key: "Authorization", prefix: "Bearer " }], + }).where(eq(toolConnections.id, remote.connection.id)); + await secrets.syncUserSecretDeclarationsForTarget(company.id, + { targetType: "tool_connection", targetId: remote.connection.id }, + [{ definitionKey: definition.key, configPath, envKey: "Authorization", versionSelector: "latest", required: true }], + { replaceAll: true }); + await db.insert(connectionGrants).values({ + companyId: company.id, connectionId: remote.connection.id, kind: "user", subjectUserId: "alice", + credentialSecretRefs: [{ secretId: secret.id, configPath, versionSelector: "latest" }], + status: "active", isDefault: false, + }); + await allowAllToolsForAgent(db, company.id, agent.id); + const headers: string[] = []; + const gateway = createTestToolGatewayService(db, { remoteHttpRequest: async (_url, init) => { + headers.push(new Headers(init.headers).get("authorization") ?? ""); + const body = JSON.parse(String(init.body)) as { id: string }; + return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, + result: { content: [{ type: "text", text: "retrieved synthetic memory" }] } }), + { headers: { "content-type": "application/json" } }); + } }); + const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const tool = (await gateway.listToolsForSession(session.token)).find(t => t.connectionId === remote.connection.id)!; + expect((await gateway.executeTool({ sessionToken: session.token, tool: tool.name, + parameters: { key: "test", value: "synthetic" } })).status).toBe("completed"); + expect(headers).toEqual(["Bearer personal-remote-test-token"]); + }, + ); + it("passes only the selected grant identity to local stdio MCP processes", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); @@ -5089,6 +5204,43 @@ rl.on("line", (line) => { }); }); + it("calls bundled Cognee in public mode and recovers from provider errors without runtime backoff", async () => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const { run } = await createIssueAndRun(db, company.id, agent.id); + const refs = await Promise.all(Object.entries({ + COGNEE_BASE_URL: "https://fixture.aws.cognee.ai", + COGNEE_API_KEY: "fixture-key", + }).map(async ([key, value]) => { + const secret = await secretService(db).create(company.id, { + name: key, key: `${key}_${randomUUID().replace(/-/g, "")}`, + provider: "local_encrypted", value, + }); + return { secretId: secret.id, versionSelector: "latest", configPath: `env.${key}`, required: true }; + })); + const local = await createLocalStdioMcpTool(db, company.id, { + applicationKey: "cognee", toolName: "recall", + connectionConfig: { templateId: "paperclip.cognee-cloud" }, credentialSecretRefs: refs, + }); + await allowAllToolsForAgent(db, company.id, agent.id); + const bridge = vi.spyOn(cogneeBridge, "callCogneeCloud") + .mockRejectedValueOnce(new Error("Cloud temporarily unavailable")) + .mockResolvedValue({ content: [{ type: "text", text: "recalled synthetic memory" }], + structuredContent: { result: "recalled synthetic memory" }, isError: false }); + const gateway = createTestToolGatewayService(db, { + deploymentMode: "authenticated", deploymentExposure: "public", trustedLocalStdioRuntimeHost: null, + }); + const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const tool = (await gateway.listToolsForSession(session.token)).find(t => t.connectionId === local.connection.id)!; + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, + parameters: { message: "synthetic" } })).rejects.toThrow("Cloud temporarily unavailable"); + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, + parameters: { message: "synthetic" } })).resolves.toMatchObject({ status: "completed" }); + expect(bridge).toHaveBeenCalledTimes(2); + expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, local.connection.id))).toHaveLength(0); + bridge.mockRestore(); + }); + it("fails closed for hosted public local stdio unless a trusted runtime host is configured", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); diff --git a/server/src/app.ts b/server/src/app.ts index ea07f8fdd53..979c64419f6 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -5,6 +5,7 @@ import { projectToolRoutes } from "./routes/project-tools.js"; import { emailChannelService } from "./services/email-channels.js"; import { emailRoutes, emailWebhookRoutes } from "./routes/email.js"; import { toolActionDeliveryService } from "./services/tool-action-delivery.js"; +import { registerAssignedMcpGateway } from "./services/native-runtime/assigned-mcp-tools.js"; import express, { Router, type Request as ExpressRequest } from "express"; import { createServer as createHttpServer, @@ -843,6 +844,7 @@ export async function createApp( })); api.use(slackToolRoutes(db, opts.authPublicBaseUrl)); app.locals.toolGateway = toolGateway; + registerAssignedMcpGateway(db, toolGateway); app.locals.toolActionDeliveries = toolActionDeliveries; app.use(mcpGatewayProtocolRoutes(toolGateway)); api.use(aiConnectionRoutes(db, { deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, trustedLocalStdioRuntimeHost })); diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 17018e2daf5..4bfff800545 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -5,6 +5,7 @@ import { and, eq, or } from "drizzle-orm"; import { APP_STORE_DEFINITIONS, isRemoteMcpConnectorId, + isMemoryConnectorId, GITHUB_CONNECTOR_PROFILES, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, isAgentStatusAssignableToWork, @@ -815,7 +816,7 @@ function connectorEnrollmentPrincipal(req: Request): string { ? await options.paperclipCloudConnector.getCapabilities() : []; const vercelConnect = vercelConnectIntegrationStatus(); - const { enableMcpAggregators } = await instanceSettingsService(db).getExperimental(); + const { enableMcpAggregators, enableMemoryConnectors } = await instanceSettingsService(db).getExperimental(); res.json({ capabilities: await describeConnectionCreateCapabilities(req, companyId), credentialSources: { @@ -831,7 +832,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)).map((app) => + apps: APP_STORE_DEFINITIONS.filter((app) => (enableMcpAggregators || !isRemoteMcpConnectorId(app.slug)) && (enableMemoryConnectors || !isMemoryConnectorId(app.slug))).map((app) => appWithPaperclipCloudConnectorAvailability(app, advertisedProfiles) ), }); diff --git a/server/src/routes/tool-gateway.ts b/server/src/routes/tool-gateway.ts index d5c992dc41d..fc733863491 100644 --- a/server/src/routes/tool-gateway.ts +++ b/server/src/routes/tool-gateway.ts @@ -204,6 +204,16 @@ async function handleMcpGatewayProtocol( } catch (err) { if (err instanceof ToolGatewayHttpError) { const id = (req.body as { id?: unknown } | undefined)?.id ?? null; + // Provider tool failures are MCP tool results, not successful calls or + // protocol errors. The service has already recorded the failed invocation. + if (req.body?.method === "tools/call" && err.reasonCode === "tool_error") { + res.json({ + jsonrpc: "2.0", + id, + result: { content: [{ type: "text", text: err.message }], isError: true }, + }); + return; + } res.status(err.status).json({ jsonrpc: "2.0", id, diff --git a/server/src/services/cognee-connection.ts b/server/src/services/cognee-connection.ts new file mode 100644 index 00000000000..792acc2319b --- /dev/null +++ b/server/src/services/cognee-connection.ts @@ -0,0 +1,115 @@ +import { createHash } from "node:crypto"; + +/** Reviewed Cognee Cloud contract from cognee-mcp 0.5.5. Executed by the bundled bridge below. */ +export const COGNEE_STDIO_TEMPLATE = { + name: "Cognee Cloud", + command: null, + args: [] as string[], + envKeys: ["COGNEE_BASE_URL", "COGNEE_API_KEY"], + tools: [ + { + name: "remember", description: "Store text in a Cognee dataset or session.", + annotations: { readOnlyHint: false }, + inputSchema: { type: "object", properties: { + data: { type: "string" }, dataset_name: { type: "string" }, + session_id: { type: "string" }, custom_prompt: { type: "string" }, + }, required: ["data"], additionalProperties: false }, + }, + { + name: "recall", description: "Search Cognee memory, optionally within named datasets or a session.", + annotations: { readOnlyHint: true }, + inputSchema: { type: "object", properties: { + query: { type: "string" }, search_type: { type: "string" }, datasets: { type: "string" }, + session_id: { type: "string" }, system_prompt: { type: "string" }, top_k: { type: "integer", minimum: 1, default: 15 }, + }, required: ["query"], additionalProperties: false }, + }, + { + name: "forget", description: "Permanently delete a Cognee dataset, or all owned memory when everything is true.", + annotations: { destructiveHint: true }, + inputSchema: { type: "object", properties: { + dataset: { type: "string" }, everything: { type: "boolean", default: false }, + }, additionalProperties: false }, + }, + ], +}; + +export function cogneeCloudUrl(value: string): URL { + const url = new URL(value); + if (url.protocol !== "https:" || !/^[a-z0-9-]+\.aws\.cognee\.ai$/i.test(url.hostname) + || url.port || url.username || url.password || url.search || url.hash || url.pathname !== "/") { + throw new Error("Copy the tenant API Base URL from Cognee's API Keys page."); + } + return url; +} + +/** + * Bundled bridge for the three reviewed Cloud operations. No package manager, + * subprocess, runtime downloads, or inherited host environment is involved. + * The gateway supplies its DNS/redirect-guarded, response-bounded HTTP client. + */ +export async function callCogneeCloud(input: { + baseUrl: string; + apiKey: string; + tool: string; + parameters: Record; + request: (url: URL, init: RequestInit) => Promise; + signal: AbortSignal; +}) { + const base = cogneeCloudUrl(input.baseUrl); + if (!input.apiKey) throw new Error("Reconnect Cognee to restore its Cloud API key."); + const tenant = /^tenant-([0-9a-f-]{36})\./i.exec(base.hostname)?.[1]; + const headers: Record = { "X-Api-Key": input.apiKey }; + if (tenant) headers["X-Tenant-Id"] = tenant; + const args = input.parameters; + const post = async (path: string, body: Record | FormData) => { + const url = new URL(path, base); + const request = new Request(url, { method: "POST", + headers: body instanceof FormData ? headers : { ...headers, "Content-Type": "application/json" }, + body: body instanceof FormData ? body : JSON.stringify(body) }); + // The guarded client pins DNS through node:http and accepts byte buffers, + // not fetch's FormData. Serialize with the matching multipart boundary. + return input.request(url, { method: "POST", signal: input.signal, + headers: Object.fromEntries(request.headers), body: Buffer.from(await request.arrayBuffer()), redirect: "manual" }); + }; + let result: unknown; + if (input.tool === "remember") { + const dataset = args.dataset_name || "paperclip_memory"; + if (args.session_id) { + if (args.custom_prompt) throw new Error("custom_prompt is not supported with session_id in Cognee Cloud."); + result = await post("/api/v1/remember/entry", { + entry: { type: "qa", question: "", answer: args.data, context: "" }, + dataset_name: dataset, session_id: args.session_id, + }); + } else { + const form = new FormData(); + form.set("data", new Blob([String(args.data)], { type: "text/plain" }), `memory_${createHash("sha256").update(String(args.data)).digest("hex")}.txt`); + form.set("datasetName", String(dataset)); + if (args.custom_prompt) form.set("custom_prompt", String(args.custom_prompt)); + result = await post("/api/v1/remember", form); + } + } else if (input.tool === "recall") { + let datasets = typeof args.datasets === "string" ? args.datasets.split(",").map(v => v.trim()).filter(Boolean) : []; + if (!datasets.length && !args.session_id) { + const available = await input.request(new URL("/api/v1/datasets/", base), { + method: "GET", headers, signal: input.signal, redirect: "manual", + }); + if (!Array.isArray(available)) throw new Error("Cognee returned an invalid dataset list."); + datasets = available.flatMap(value => value && typeof value.name === "string" ? [value.name] : []); + } + result = await post("/api/v1/recall", { + query: args.query, top_k: args.top_k ?? 15, + search_type: typeof args.search_type === "string" ? args.search_type.toUpperCase() : null, + ...(datasets.length ? { datasets } : {}), + ...(args.session_id ? { session_id: args.session_id } : {}), + ...(args.system_prompt ? { system_prompt: args.system_prompt } : {}), + }); + } else if (input.tool === "forget") { + if (!args.dataset && args.everything !== true) throw new Error("Specify a dataset or set everything to true."); + result = await post("/api/v1/forget", { + everything: args.everything === true, ...(args.dataset ? { dataset: args.dataset } : {}), + }); + } else { + throw new Error("Unreviewed Cognee action."); + } + return { content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: { result }, isError: false }; +} diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 19691b25908..26761267215 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -235,6 +235,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableApps: 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, enableAgentChat: parsed.data.enableAgentChat ?? false, @@ -277,6 +278,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableApps: true, enableChatConnectors: false, enableMcpAggregators: false, + enableMemoryConnectors: false, enablePipelines: false, enableCases: false, enableAgentChat: false, diff --git a/server/src/services/native-runtime/assigned-mcp-tools.test.ts b/server/src/services/native-runtime/assigned-mcp-tools.test.ts new file mode 100644 index 00000000000..be9c342d052 --- /dev/null +++ b/server/src/services/native-runtime/assigned-mcp-tools.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Db } from "@paperclipai/db"; +import { ToolGatewayHttpError, type ToolGatewayDescriptor, type ToolGatewayService } from "../tool-gateway.js"; +import { createAssignedMcpTools, getAssignedMcpGateway, registerAssignedMcpGateway } from "./assigned-mcp-tools.js"; + +function descriptor(name: string, risk: ToolGatewayDescriptor["risk"] = "read"): ToolGatewayDescriptor { + return { name, displayName: name, description: `Use ${name}`, parametersSchema: { type: "object", properties: { query: { type: "string" } } }, pluginId: "fixture", providerType: "mcp_remote_http", risk }; +} + +function fixture(tools: ToolGatewayDescriptor[]) { + const listToolsForNamedGateway = vi.fn().mockResolvedValue(tools); + const executeTool = vi.fn().mockResolvedValue({ status: "completed", result: { content: [{ type: "text", text: "memory found" }] } }); + const gateway = { listToolsForNamedGateway, executeTool } as unknown as ToolGatewayService; + return { gateway, listToolsForNamedGateway, executeTool, gatewayPublicId: "gateway-fixture", bearerToken: "private-run-token-never-project" }; +} + +describe("assigned MCP runner tools", () => { + it("requires a configured gateway registered for the exact database instance", () => { + const firstDb = {} as Db; + const secondDb = {} as Db; + const first = fixture([]).gateway; + const second = fixture([]).gateway; + expect(() => getAssignedMcpGateway(firstDb)).toThrow("assigned_mcp_gateway_unavailable"); + registerAssignedMcpGateway(firstDb, first); + expect(getAssignedMcpGateway(firstDb)).toBe(first); + expect(() => getAssignedMcpGateway(secondDb)).toThrow("assigned_mcp_gateway_unavailable"); + registerAssignedMcpGateway(secondDb, second); + expect(getAssignedMcpGateway(secondDb)).toBe(second); + expect(getAssignedMcpGateway(firstDb)).toBe(first); + registerAssignedMcpGateway(firstDb, second); + expect(getAssignedMcpGateway(firstDb)).toBe(second); + }); + + it("projects only authorized gateway tools with stable bounded collision-resistant names", async () => { + const names = ["memory.search", "memory-search", "x".repeat(120), "🧠"]; + const f = fixture(names.map(name => descriptor(name))); + const first = await createAssignedMcpTools(f); + const reversed = await createAssignedMcpTools({ ...f, gateway: fixture([...names].reverse().map(name => descriptor(name))).gateway }); + const projected = first.definitions().map(tool => tool.name as string); + expect(new Set(projected).size).toBe(names.length); + for (const name of projected) { + expect(name).toMatch(/^app_[a-zA-Z0-9_]+_[a-f0-9]{12}$/); + expect(name.length).toBeLessThanOrEqual(64); + expect(first.has(name)).toBe(true); + } + expect(reversed.definitions().map(tool => tool.name)).toEqual([...projected].reverse()); + expect(f.listToolsForNamedGateway).toHaveBeenCalledWith({ gatewayPublicId: f.gatewayPublicId, bearerToken: f.bearerToken }); + }); + + it("keeps the provider and action readable for fully namespaced gateway tools", async () => { + const prefix = "mcp.app-gallery-mem0-60062edb-c211-4aae-a787-b4fa12a5ea24-f4ece062"; + const tools = ["add-memory", "search-memories", "delete-memory"].map(action => ({ + ...descriptor(`${prefix}:${action}`), displayName: `Mem0 ${action}`, + })); + const assigned = await createAssignedMcpTools(fixture(tools)); + for (const [index, definition] of assigned.definitions().entries()) { + const name = definition.name as string; + expect(name).toContain("mem0"); + expect(name).toContain(tools[index]!.name.split(":").at(-1)!.replaceAll("-", "_")); + expect(name.length).toBeLessThanOrEqual(64); + expect(definition.description).toBe(`${tools[index]!.displayName}: ${tools[index]!.description}`); + } + const long = await createAssignedMcpTools(fixture([descriptor(`${prefix}:${"long-action-".repeat(10)}`)])); + expect((long.definitions()[0]!.name as string).length).toBeLessThanOrEqual(64); + }); + + it("does not project gateway secrets, URLs, or provider metadata and returns only the tool result", async () => { + const tool = { ...descriptor("recall"), providerMetadata: { token: "private-provider-token", url: "https://private-gateway.example" } }; + const f = fixture([tool]); + f.executeTool.mockResolvedValue({ status: "completed", invocationId: "internal", result: { memories: [] } }); + const assigned = await createAssignedMcpTools(f); + const definitions = assigned.definitions(); + expect(Object.keys(definitions[0]!)).toEqual(["name", "description", "inputSchema"]); + expect(JSON.stringify(definitions)).not.toMatch(/private-|gateway-fixture|https:/); + const args = { query: "synthetic memory" }; + expect(await assigned.execute({ tool: definitions[0]!.name as string, arguments: args })).toEqual({ memories: [] }); + expect(f.executeTool).toHaveBeenCalledExactlyOnceWith({ gatewayPublicId: f.gatewayPublicId, sessionToken: f.bearerToken, tool: "recall", parameters: args }); + (definitions[0]!.inputSchema as Record).type = "string"; + expect(assigned.definitions()[0]!.inputSchema).toEqual(tool.parametersSchema); + }); + + it("rejects unknown names without invoking the gateway", async () => { + const f = fixture([descriptor("recall")]); + const assigned = await createAssignedMcpTools(f); + expect(assigned.has("unassigned")).toBe(false); + await expect(assigned.execute({ tool: "unassigned", arguments: {} })).rejects.toThrow("assigned_mcp_tool_unknown"); + expect(f.executeTool).not.toHaveBeenCalled(); + }); + + it.each(["planning", "ask"] as const)("exposes and permits only read tools in %s mode", async workMode => { + const f = fixture([descriptor("read"), descriptor("remember", "write"), descriptor("forget", "destructive")]); + const standard = await createAssignedMcpTools(f); + const restricted = await createAssignedMcpTools({ ...f, workMode }); + expect(restricted.definitions()).toEqual([standard.definitions()[0]]); + for (const tool of standard.definitions().slice(1)) { + await expect(restricted.execute({ tool: tool.name as string, arguments: {} })).rejects.toThrow("paperclip_runner_tool_mode_denied"); + } + expect(f.executeTool).not.toHaveBeenCalled(); + await restricted.execute({ tool: restricted.definitions()[0]!.name as string, arguments: {} }); + expect(f.executeTool).toHaveBeenCalledOnce(); + }); + + it("uses the gateway for write and destructive tools under standard mode without approval overrides", async () => { + const f = fixture([descriptor("remember", "write"), descriptor("forget", "destructive")]); + const assigned = await createAssignedMcpTools(f); + for (const tool of assigned.definitions()) await assigned.execute({ tool: tool.name as string, arguments: {} }); + expect(f.executeTool).toHaveBeenCalledTimes(2); + for (const [call] of f.executeTool.mock.calls) expect(call).not.toHaveProperty("approvedActionRequestId"); + }); + + it.each(["planning", "ask"] as const)("enforces fresh %s mode without relaxing the pinned mode", async currentMode => { + const f = fixture([descriptor("read"), descriptor("remember", "write")]); + const standard = await createAssignedMcpTools(f); + const restricted = await createAssignedMcpTools({ ...f, workMode: currentMode }); + const write = { tool: standard.definitions()[1]!.name as string, arguments: {} }; + await expect(standard.execute(write, currentMode)).rejects.toThrow("paperclip_runner_tool_mode_denied"); + await expect(restricted.execute(write, "standard")).rejects.toThrow("paperclip_runner_tool_mode_denied"); + expect(f.executeTool).not.toHaveBeenCalled(); + await standard.execute({ tool: standard.definitions()[0]!.name as string, arguments: {} }, currentMode); + expect(f.executeTool).toHaveBeenCalledOnce(); + }); + + it.each(["tool_error", "approval_required", "connection_revoked"])("propagates %s without converting it to success", async reason => { + const f = fixture([descriptor("recall")]); + const error = new ToolGatewayHttpError(403, "Gateway refused execution", reason); + f.executeTool.mockRejectedValue(error); + const assigned = await createAssignedMcpTools(f); + await expect(assigned.execute({ tool: assigned.definitions()[0]!.name as string, arguments: {} })).rejects.toBe(error); + }); + + it("propagates discovery denial", async () => { + const f = fixture([]); + const error = new Error("gateway_token_revoked"); + f.listToolsForNamedGateway.mockRejectedValue(error); + await expect(createAssignedMcpTools(f)).rejects.toBe(error); + }); + + it("rejects error payloads and incomplete outcomes, and accepts successful replay", async () => { + const f = fixture([descriptor("recall")]); + const assigned = await createAssignedMcpTools(f); + const call = { tool: assigned.definitions()[0]!.name as string, arguments: {} }; + f.executeTool.mockResolvedValueOnce({ status: "completed", result: { isError: true, content: [] } }); + await expect(assigned.execute(call)).rejects.toMatchObject({ reasonCode: "tool_error" }); + f.executeTool.mockResolvedValueOnce({ status: "pending", result: {} }); + await expect(assigned.execute(call)).rejects.toThrow("assigned_mcp_tool_execution_incomplete"); + f.executeTool.mockResolvedValueOnce({ status: "replayed", result: { memory: "synthetic" } }); + await expect(assigned.execute(call)).resolves.toEqual({ memory: "synthetic" }); + }); +}); diff --git a/server/src/services/native-runtime/assigned-mcp-tools.ts b/server/src/services/native-runtime/assigned-mcp-tools.ts new file mode 100644 index 00000000000..816b3e84655 --- /dev/null +++ b/server/src/services/native-runtime/assigned-mcp-tools.ts @@ -0,0 +1,85 @@ +import { createHash } from "node:crypto"; +import type { Db } from "@paperclipai/db"; +import { ToolGatewayHttpError, type ToolGatewayDescriptor, type ToolGatewayService } from "../tool-gateway.js"; + +type WorkMode = "standard" | "planning" | "ask"; + +// Execution must use the app's configured gateway, including deployment +// restrictions, OAuth refresh, and approval delivery. Never fall back to an +// isolated gateway whose defaults differ from the running instance. +const assignedMcpGateways = new WeakMap(); + +export function registerAssignedMcpGateway(db: Db, gateway: ToolGatewayService): void { + assignedMcpGateways.set(db, gateway); +} + +export function getAssignedMcpGateway(db: Db): ToolGatewayService { + const gateway = assignedMcpGateways.get(db); + if (!gateway) throw new Error("assigned_mcp_gateway_unavailable"); + return gateway; +} + +function assignedToolName(name: string): string { + const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_]+/g, "_").replace(/^_+|_+$/g, ""); + const separator = name.lastIndexOf(":"); + const readable = separator >= 0 + ? `${sanitize(name.slice(0, separator)).slice(0, 20) || "tool"}_${sanitize(name.slice(separator + 1)).slice(-26) || "action"}` + : sanitize(name).slice(0, 47) || "tool"; + const digest = createHash("sha256").update(name).digest("hex").slice(0, 12); + return `app_${readable}_${digest}`; +} + +/** Server-owned MCP bindings projected into the runner's existing tool channel. */ +export async function createAssignedMcpTools(input: { + gateway: ToolGatewayService; + gatewayPublicId: string; + bearerToken: string; + workMode?: WorkMode; +}) { + const listed = await input.gateway.listToolsForNamedGateway({ + gatewayPublicId: input.gatewayPublicId, + bearerToken: input.bearerToken, + }); + const tools = new Map(); + for (const descriptor of listed) { + const name = assignedToolName(descriptor.name); + if (tools.has(name)) throw new Error("assigned_mcp_tool_name_collision"); + tools.set(name, descriptor); + } + const permits = (tool: ToolGatewayDescriptor, mode: WorkMode = input.workMode ?? "standard") => mode === "standard" || tool.risk === "read"; + + return { + definitions(): Array> { + return [...tools].filter(([, tool]) => permits(tool)).map(([name, tool]) => ({ + name, + description: `${tool.displayName}: ${tool.description}`, + inputSchema: structuredClone(tool.parametersSchema), + })); + }, + has(name: string): boolean { + return tools.has(name); + }, + async execute(call: { tool: string; arguments: unknown }, currentWorkMode?: WorkMode): Promise { + const descriptor = tools.get(call.tool); + if (!descriptor) throw new Error("assigned_mcp_tool_unknown"); + if (!permits(descriptor) || !permits(descriptor, currentWorkMode)) throw new Error("paperclip_runner_tool_mode_denied"); + // Reauthorize through the existing gateway on every call. Discovery is + // not a grant: revocation, policy, approval, and audit remain server-owned. + const result = await input.gateway.executeTool({ + gatewayPublicId: input.gatewayPublicId, + sessionToken: input.bearerToken, + tool: descriptor.name, + parameters: call.arguments, + }); + if (result.status !== "completed" && result.status !== "replayed") { + throw new Error("assigned_mcp_tool_execution_incomplete"); + } + // The gateway normally throws provider errors. Keep a malformed or older + // provider response from being reported as successful through PRP. + if (result.result && typeof result.result === "object" && "isError" in result.result && result.result.isError === true) { + throw new ToolGatewayHttpError(502, "The assigned tool returned an error.", "tool_error"); + } + return result.result; + }, + }; +} diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 4bd73dce899..3274b2ed3f9 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -127,6 +127,7 @@ const durableRunnerState = ( }); const state = vi.hoisted(() => ({ + createAssignedMcpTools: vi.fn(), execute: vi.fn(), cleanup: vi.fn(), retireCleanup: vi.fn(), @@ -219,6 +220,11 @@ vi.mock("./paperclip-runner-tool-authority.js", () => ({ }, })); +vi.mock("./assigned-mcp-tools.js", () => ({ + createAssignedMcpTools: state.createAssignedMcpTools, + getAssignedMcpGateway: () => ({}), +})); + vi.mock("./native-runner-file-handoff.js", () => ({ stageNativeRunnerWakeAttachments: state.stageNativeRunnerWakeAttachments, renderNativeRunnerStagedAttachmentPrompt: @@ -304,6 +310,7 @@ import { } from "./native-session-executor.js"; beforeEach(() => { + state.createAssignedMcpTools.mockReset(); state.resolveRunnerBinary.mockReset().mockReturnValue("/tmp/paperclip-runnerd"); state.resolveCurrentWakeCommentsBinding.mockReset().mockResolvedValue(null); state.assertCurrentWakeCommentsRead.mockReset().mockResolvedValue(undefined); @@ -7997,6 +8004,52 @@ describe("runnerd provider runtime wiring", () => { } }); + it.each([ + { PAPERCLIP_NATIVE_MCP_NAME: "paperclip-assigned" }, + { PAPERCLIP_NATIVE_MCP_URL: "http://127.0.0.1:3217/mcp/gateways/test" }, + { PAPERCLIP_NATIVE_MCP_TOKEN: "private-run-token" }, + ])("rejects partial remote assigned MCP bindings", async (runnerEnvironment) => { + await expect(createRunnerdBackend({ + db: leaseDb(execution), execution, runnerInstanceId: "runner-partial-mcp", runnerEnvironment, + runnerExecutionTarget: { + kind: "remote", transport: "sandbox", providerKey: "daytona", + leaseId: "lease-partial-mcp", remoteCwd: "/home/daytona/paperclip-workspace", + runner: { execute: vi.fn() }, + } as never, + })).rejects.toThrow("assigned native MCP launch binding is incomplete"); + expect(state.createAssignedMcpTools).not.toHaveBeenCalled(); + }); + + it("keeps assigned MCP credentials on the control plane for remote Codex", async () => { + const assignedMcpTools = { definitions: () => [], has: () => false, execute: vi.fn() }; + state.createAssignedMcpTools.mockResolvedValueOnce(assignedMcpTools); + state.createBackend.mockClear(); + state.createTransport.mockClear(); + state.toolAuthorityDefinitions.mockClear(); + await createRunnerdBackend({ + db: leaseDb(execution), execution, runnerInstanceId: "runner-assigned-mcp", + runnerEnvironment: { + PAPERCLIP_NATIVE_MCP_NAME: "paperclip-assigned", + PAPERCLIP_NATIVE_MCP_URL: "http://127.0.0.1:3217/mcp/gateways/assigned-test", + PAPERCLIP_NATIVE_MCP_TOKEN: "private-run-token", + }, + runnerExecutionTarget: { + kind: "remote", transport: "sandbox", providerKey: "daytona", + leaseId: "lease-assigned-mcp", remoteCwd: "/home/daytona/paperclip-workspace", + runner: { execute: vi.fn() }, + } as never, + }); + expect(state.createAssignedMcpTools).toHaveBeenCalledWith(expect.objectContaining({ + gatewayPublicId: "assigned-test", bearerToken: "private-run-token", + })); + expect(state.toolAuthorityDefinitions).toHaveBeenCalledWith(expect.objectContaining({ assignedMcpTools })); + state.createBackend.mock.calls[0]![1].codexTransportFactory!(); + const options = state.createTransport.mock.calls[0]![0] as { environment: NodeJS.ProcessEnv }; + expect(options.environment.PAPERCLIP_NATIVE_MCP_NAME).toBeUndefined(); + expect(options.environment.PAPERCLIP_NATIVE_MCP_URL).toBeUndefined(); + expect(options.environment.PAPERCLIP_NATIVE_MCP_TOKEN).toBeUndefined(); + }); + it("makes remote authority archival idempotent and returns the archived state", async () => { const remoteExecute = vi.fn(); const remoteTarget = { diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index e6ec8839124..9c0adb000c5 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -122,6 +122,7 @@ import { PaperclipControlPlanePort } from "./paperclip-control-plane-port.js"; import { appendHeartbeatRunEvent } from "../heartbeat-run-events.js"; import { nativeSha256 } from "./canonical.js"; import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; +import { createAssignedMcpTools, getAssignedMcpGateway } from "./assigned-mcp-tools.js"; import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-review-participant.js"; import { NativeChatAttachmentReadScope } from "./chat-attachment-read.js"; import { @@ -10463,9 +10464,35 @@ async function createRunnerdBackendWithinSessionClaim( if (nativeReview && !await getNativeReviewAssignment(input.db, { ...input.execution.binding, contextSnapshot: nativeReview, })) throw new Error("native_review_assignment_no_longer_available"); + // Remote Codex already sends dynamic tool calls over authenticated PRP. Keep + // the assigned gateway on the control plane instead of asking the sandbox to + // reach the host's HTTP origin (which may be private or loopback-only). + const relayAssignedMcp = remoteTarget !== null && input.execution.provider.kind === "codex"; + const assignedMcpUrl = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_URL; + const assignedMcpToken = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_TOKEN; + const assignedMcpName = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_NAME; + const hasAssignedMcp = Boolean(assignedMcpName || assignedMcpUrl || assignedMcpToken); + if (relayAssignedMcp && hasAssignedMcp && (!assignedMcpName?.trim() || !assignedMcpUrl?.trim() || !assignedMcpToken?.trim())) { + throw new Error("assigned native MCP launch binding is incomplete"); + } + const assignedGatewayPublicId = relayAssignedMcp && assignedMcpUrl + ? new URL(assignedMcpUrl).pathname.match(/^\/mcp\/gateways\/([a-zA-Z0-9_-]+)$/)?.[1] + : undefined; + if (relayAssignedMcp && hasAssignedMcp && !assignedGatewayPublicId) { + throw new Error("assigned native MCP gateway path is invalid"); + } + const assignedMcpTools = relayAssignedMcp && !nativeReview && assignedMcpUrl && assignedMcpToken + ? await createAssignedMcpTools({ + gateway: getAssignedMcpGateway(input.db), + gatewayPublicId: assignedGatewayPublicId!, + bearerToken: assignedMcpToken, + workMode: input.execution.task.workMode, + }) + : undefined; const authority = new PaperclipRunnerToolAuthority(input.db, { ...(nativeReview ? { nativeReview } : {}), connectorAssignments: connectorAssignments.filter((assignment) => pinnedSkills.has(assignment.skillKey)), + assignedMcpTools, companyId: input.execution.binding.companyId, issueId: input.execution.binding.issueId, runId: input.execution.binding.runId, @@ -12024,6 +12051,13 @@ async function createRunnerdBackendWithinSessionClaim( const effectiveRunnerEnvironmentBase: NodeJS.ProcessEnv = { ...(input.runnerEnvironment ?? process.env), }; + if (relayAssignedMcp) { + // The server-held tool authority owns this credential. Do not deliver a + // duplicate HTTP MCP server or its bearer token to the remote provider. + delete effectiveRunnerEnvironmentBase.PAPERCLIP_NATIVE_MCP_NAME; + delete effectiveRunnerEnvironmentBase.PAPERCLIP_NATIVE_MCP_URL; + delete effectiveRunnerEnvironmentBase.PAPERCLIP_NATIVE_MCP_TOKEN; + } // This authority bit is derived only from the selected execution target. // Never let an agent, environment binding, or host variable disable the // Codex sandbox for a local runner by supplying the same key. diff --git a/server/src/services/native-runtime/native-session-resume.ts b/server/src/services/native-runtime/native-session-resume.ts index 08182f652ba..a14c64ad70a 100644 --- a/server/src/services/native-runtime/native-session-resume.ts +++ b/server/src/services/native-runtime/native-session-resume.ts @@ -38,6 +38,9 @@ export function nativeToolContractFingerprintForTarget( "always_advertised_run_issue_agent_binding_gated_current_task_description.v2", semanticCompletion: "finish_accessible_deliverable_evidence.v4", connectorTools: "assigned_resources_and_pinned_skill_bundle.v1", + ...(executionTargetKind === "remote" + ? { assignedMcpTools: "codex_server_gateway_prp_relay.v1" } + : {}), }, tools: [ { name: "register_deliverable", version: 2 }, diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts index 4639b8de230..1dc5d9de198 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts @@ -596,6 +596,34 @@ describe("PaperclipRunnerToolAuthority", () => { ).resolves.toMatchObject({ approval: { id: approvalId }, tasks: [] }); }); + it("relays assigned MCP calls only while the native run still owns its task", async () => { + const tool = { name: "app_mem0_recall", description: "Recall memory", inputSchema: { type: "object" } }; + const execute = vi.fn().mockResolvedValue({ content: "synthetic memory" }); + const authority = new PaperclipRunnerToolAuthority(db, { + companyId, agentId, issueId, runId, + assignedMcpTools: { definitions: () => [tool], has: (name) => name === tool.name, execute }, + }); + expect(authority.definitions()).toContainEqual(tool); + const call = { tool: tool.name, callId: "assigned-mcp", arguments: { query: "compass" } }; + await expect(authority.execute(call)).resolves.toEqual({ content: "synthetic memory" }); + expect(execute).toHaveBeenCalledWith(call, "standard"); + await db.update(issues).set({ workMode: "ask" }).where(eq(issues.id, issueId)); + try { + await authority.execute(call); + expect(execute).toHaveBeenLastCalledWith(call, "ask"); + } finally { + await db.update(issues).set({ workMode: "standard" }).where(eq(issues.id, issueId)); + } + execute.mockClear(); + await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId)); + try { + await expect(authority.execute(call)).rejects.toThrow("paperclip_runner_tool_binding_not_authorized"); + expect(execute).not.toHaveBeenCalled(); + } finally { + await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, runId)); + } + }); + it("does not advertise delegation tools during pre-acceptance planning", () => { const authority = new PaperclipRunnerToolAuthority(db, { companyId, 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 4e0cf9236f2..2e3b70c4b68 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -1,4 +1,5 @@ import { publicChatTaskUrl } from "../chat-task-url.js"; +import type { createAssignedMcpTools } from "./assigned-mcp-tools.js"; import { assertAssignableAgent } from "../agent-assignability.js"; import { authorizationService } from "../authorization.js"; import { handoffPlanContext } from "./handoff-plan-context.js"; @@ -104,6 +105,7 @@ type Binding = { storage?: StorageService; /** Server-owned suppression for baseline evals; true never overrides operator opt-in. */ connectorAssignments?: ConnectorAssignment[]; + assignedMcpTools?: Awaited>; apiToolsEnabled?: boolean; workMode?: "standard" | "planning" | "ask"; workspaceRoot?: string; @@ -223,7 +225,7 @@ export class PaperclipRunnerToolAuthority { definitions.push(LIST_CHAT_ATTACHMENTS_TOOL_DEFINITION); definitions.push(REUSE_CHAT_ATTACHMENT_TOOL_DEFINITION); definitions.push(READ_CHAT_ATTACHMENT_TOOL_DEFINITION); - return [...RUNTIME_CONNECTION_TOOL_DEFINITIONS, ...(this.binding.connectorAssignments ?? []).flatMap((assignment) => assignment.tools), ...definitions]; + return [...RUNTIME_CONNECTION_TOOL_DEFINITIONS, ...(this.binding.connectorAssignments ?? []).flatMap((assignment) => assignment.tools), ...(this.binding.assignedMcpTools?.definitions() ?? []), ...definitions]; } async execute(call: { @@ -237,6 +239,10 @@ export class PaperclipRunnerToolAuthority { throw forbidden("This review run may only inspect the assigned task and resolve its review."); } } + if (this.binding.assignedMcpTools?.has(call.tool)) { + const current = await this.#boundContext(); + return this.binding.assignedMcpTools.execute(call, current.issue.workMode as "standard" | "planning" | "ask"); + } if (isConnectorTool(call.tool)) { if (!(this.binding.connectorAssignments ?? []).some((assignment) => assignment.tools.some((tool) => tool.name === call.tool))) throw forbidden("Connector tool is not available to this run"); const { run } = await this.#boundContext(); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index e4dc96b87b8..f2f2f062b67 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -1,4 +1,5 @@ -import { isRemoteMcpConnectorMethod, connectionPurposeTransportSchema } from "@paperclipai/shared"; +import { COGNEE_STDIO_TEMPLATE, cogneeCloudUrl } from "./cognee-connection.js"; +import { isMemoryConnectorId, isRemoteMcpConnectorMethod, connectionPurposeTransportSchema } from "@paperclipai/shared"; import { instanceSettingsService } from "./instance-settings.js"; import { githubBotRequest } from "./chat-github-client.js"; import { syncConnectionCredentialBindings } from "./connection-credential-bindings.js"; @@ -815,6 +816,7 @@ const APPROVED_STDIO_TEMPLATES: Record< }, ], }, + "paperclip.cognee-cloud": COGNEE_STDIO_TEMPLATE, "paperclip.google-sheets": { name: "Google Sheets", command: "paperclip-google-sheets-mcp-server", @@ -1040,7 +1042,7 @@ function credentialFieldsFor(app: AppDefinition, methodKey?: string | null) { const method = connectionMethodFor(app, methodKey); return (method.credentialFields ?? []).map((field) => ({ label: field.label, - configPath: credentialConfigPath(field), + configPath: credentialConfigPath(field, method), helpUrl: method.consoleLinks?.keys ?? method.consoleLinks?.docs ?? "", required: field.required, placement: @@ -2342,6 +2344,15 @@ export function classifyRisk( if (annotations.destructiveHint === true || annotations.destructive === true) return "destructive"; const normalizedToolName = normalizedProviderToolName(tool.name); + if (isMemoryConnectorId(sourceTemplateKey)) { + if (verbMatches(tool.name, "delete|remove|destroy|forget|prune|reset")) return "destructive"; + // Supermemory uses one add_memory tool for both save and forget. + if (sourceTemplateKey === "supermemory" && normalizedToolName === "add-memory") return "destructive"; + if (verbMatches(tool.name, "add|remember|save|record|ingest|cognify|update|create|set|upload|select|rename|share")) return "write"; + if (annotations.readOnlyHint === false || annotations.writeHint === true) return "write"; + const reads = ["get", "list", "search", "recall", "query", "retrieve", "who"]; + return verbMatches(tool.name, reads.join("|")) ? "read" : "write"; + } const reviewedReads = AGGREGATOR_READ_TOOLS.get(sourceTemplateKey ?? ""); if (reviewedReads) { if (verbMatches(tool.name, "delete|remove|destroy|unpublish")) return "destructive"; @@ -2827,6 +2838,7 @@ function healthFailureHttpStatus(failure: { if (failure.code === "user_authorization_required") return 422; if (failure.code === "composio_broker_retired") return 422; if (failure.code === "tool_connection_transport_unsupported") return 422; + if (failure.code === "cognee_access_unverified" || failure.code === "memory_api_key_rejected") return 422; if (failure.code.endsWith("_endpoint_rejected")) return 422; return 502; } @@ -2845,6 +2857,9 @@ function sanitizeHttpFailure(error: unknown): { } { if (error instanceof HttpError) { const code = asRecord(error.details).code; + if (code === "cognee_access_unverified" || code === "memory_api_key_rejected") { + return { status: "error", message: error.message, code }; + } if (code === "slack_mcp_access_disabled") { return { status: "error", message: error.message, code }; } @@ -3509,7 +3524,9 @@ export function toolAccessService( function assertLocalStdioCanBeEnabled( transport: ToolConnectionTransport, enabled: boolean, + config: Record = {}, ) { + if (config.templateId === "paperclip.cognee-cloud") return; if ( transport === "local_stdio" && enabled && @@ -6496,7 +6513,7 @@ export function toolAccessService( async function ensureRuntimeSlot( connection: typeof toolConnections.$inferSelect, ): Promise { - if (connection.transport !== "local_stdio") return null; + if (connection.transport !== "local_stdio" || connection.config.templateId === "paperclip.cognee-cloud") return null; const slotKey = `mcp:${connection.companyId}:${connection.id}`; const [existing] = await db .select() @@ -6883,6 +6900,10 @@ export function toolAccessService( } } if (!response.ok) { + if ((response.status === 401 || response.status === 403) + && connection.authKind === "api_key" && isMemoryConnectorId(connection.config.sourceTemplateKey)) { + throw unprocessable("The provider rejected this API key. Check the key and its account access, then try again.", { code: "memory_api_key_rejected" }); + } if (await isSlackMcpAccessDisabledResponse(endpoint, response)) { throw unprocessable( "Slack MCP access is disabled for this app. Ask the Slack app owner to enable MCP access, then refresh this connection.", @@ -6990,6 +7011,34 @@ export function toolAccessService( return apiStatus === "available" ? [...descriptors, ...RAILWAY_TOOLS] : descriptors; } + async function validateCogneeConnection(connection: typeof toolConnections.$inferSelect, actor?: ActorInfo, probe = true) { + if (connection.config.templateId !== "paperclip.cognee-cloud") return; + const grant = await vaultGrantForConnection(connection, actor); + const refs = grant?.credentialSecretRefs ?? connection.credentialSecretRefs; + const values: Record = {}; + for (const key of COGNEE_STDIO_TEMPLATE.envKeys) { + const ref = refs.find((candidate) => candidate.configPath === `env.${key}`); + if (!ref) throw unprocessable("Reconnect Cognee to restore its Cloud credentials", { code: "missing_secret" }); + values[key] = grant + ? (await resolveOAuthGrantSecret(connection, grant, ref, actor, undefined)).value + : await secrets.resolveSecretValue(connection.companyId, ref.secretId, ref.versionSelector ?? "latest", { + consumerType: "tool_connection", consumerId: connection.id, configPath: ref.configPath, + actorType: "system", actorId: null, + }); + } + let base: URL; + try { base = cogneeCloudUrl(values.COGNEE_BASE_URL!); } + catch { throw badRequest("Copy the tenant API Base URL from Cognee’s API Keys page."); } + // Scheduled health checks validate configuration and vault access. A transient + // Cloud probe must not withdraw an already assigned local tool catalog. + if (!probe) return; + const response = await requestRemoteHttpEndpoint(new URL("/api/v1/datasets/", base), { + method: "GET", headers: { "X-Api-Key": values.COGNEE_API_KEY! }, signal: AbortSignal.timeout(15_000), + }); + await response.body?.cancel(); + if (!response.ok) throw unprocessable("Cognee Cloud could not verify access. Check the tenant URL, API key, and workspace subscription.", { code: "cognee_access_unverified" }); + } + async function localTools( connection: typeof toolConnections.$inferSelect, ): Promise { @@ -7062,6 +7111,7 @@ export function toolAccessService( throw unsupportedToolConnectionTransport(); } await resolveCredentialHeaders(connection); + await validateCogneeConnection(connection, actor); return localTools(connection); } @@ -7223,6 +7273,7 @@ export function toolAccessService( } else if (connection.transport === "local_stdio") { await resolveCredentialHeaders(connection); await stdioTemplateId(connection.companyId, connection.config); + await validateCogneeConnection(connection, actor, false); } else { throw unsupportedToolConnectionTransport(); } @@ -11818,7 +11869,11 @@ export function toolAccessService( return `${base.slice(0, 151).trimEnd()} (${randomUUID().slice(0, 6)})`; } - async function assertMcpAggregatorSetupEnabled(provider: unknown, method: unknown) { + async function assertExperimentalConnectorSetupEnabled(provider: unknown, method: 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", { @@ -11975,7 +12030,10 @@ export function toolAccessService( ? connectionMethodFor(galleryEntry, inferredMethodKey) : null; const remoteMcpConnector = isRemoteMcpConnectorMethod(galleryEntry?.slug, method?.key); - await assertMcpAggregatorSetupEnabled(galleryEntry?.slug, method?.key); + await assertExperimentalConnectorSetupEnabled(galleryEntry?.slug, method?.key, Boolean( + input.reconnectConnectionId && requestedResumeConnection?.status !== "draft" + && requestedResumeConnection?.config.sourceTemplateKey === galleryEntry?.slug, + )); if (galleryEntry && input.link) { const acceptsProviderGeneratedUrl = method?.transport === "mcp_remote" && @@ -12403,6 +12461,7 @@ export function toolAccessService( []; const credentialRefs: McpConnectionCredentialRef[] = []; const createdSecretIds: string[] = []; + const createdDefinitionIds: string[] = []; // "Just me" needs a named board user to own the consent. An agent actor // cannot hold a personal identity, and silently falling back to a shared // credential is exactly the mis-scoping the design forbids, so refuse. @@ -12498,17 +12557,28 @@ export function toolAccessService( throw badRequest(`Missing credential value for ${field.configPath}`); } if (!value) continue; - const secret = await secrets.create( - companyId, - { - name: `${name} ${field.label} ${randomUUID().slice(0, 8)}`, - key: `tool_app.${randomUUID()}.${field.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`, - provider: "local_encrypted", - value, - description: `Credential for ${name} (${field.configPath}).`, - }, - actorForSecret(actor), - ); + const secretMetadata = { + name: `${name} ${field.label} ${randomUUID().slice(0, 8)}`, + key: `tool_app.${randomUUID()}.${field.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`, + provider: "local_encrypted" as const, + description: `Credential for ${name} (${field.configPath}).`, + }; + // A personal grant must own a user-scoped vault value. Merely keeping a + // company secret off the connection row does not establish ownership. + const secret = personalIdentityUserId + ? await db.transaction(async (tx) => { + const personalSecrets = secretService(tx as unknown as Db); + const definition = await personalSecrets.createUserSecretDefinition( + companyId, secretMetadata, actorForSecret(actor), + ); + const valueRow = await personalSecrets.createCurrentUserSecretValue( + companyId, personalIdentityUserId, + { definitionId: definition.id, value }, actorForSecret(actor), + ); + createdDefinitionIds.push(definition.id); + return valueRow; + }) + : await secrets.create(companyId, { ...secretMetadata, value }, actorForSecret(actor)); createdSecretIds.push(secret.id); credentialSecretRefs.push({ secretId: secret.id, @@ -13247,6 +13317,9 @@ export function toolAccessService( for (const secretId of createdSecretIds) { await secrets.remove(secretId).catch(() => undefined); } + for (const definitionId of createdDefinitionIds) { + await db.delete(userSecretDefinitions).where(eq(userSecretDefinitions.id, definitionId)).catch(() => undefined); + } } if (identityRollbackError) { throw new HttpError( @@ -13887,7 +13960,7 @@ export function toolAccessService( ): Promise { const connection = await getConnectionRow(connectionId, companyId); assertSupportedConnection(connection); - await assertMcpAggregatorSetupEnabled(connection.config.sourceTemplateKey, connection.config.connectionMethodKey); + await assertExperimentalConnectorSetupEnabled(connection.config.sourceTemplateKey, connection.config.connectionMethodKey, connection.status !== "draft"); if (connection.status === "archived") throw conflict("Archived app connections cannot be reconnected"); if (connection.credentialSource === "vercel_connect") { @@ -13956,17 +14029,20 @@ export function toolAccessService( ); continue; } - const secret = await secrets.create( - companyId, - { - name: `${connection.name} ${field.label} ${randomUUID().slice(0, 8)}`, - key: `tool_app.${randomUUID()}.${field.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`, - provider: "local_encrypted", - value, - description: `Credential for ${connection.name} (${field.configPath}).`, - }, - actorForSecret(actor), - ); + const metadata = { + name: `${connection.name} ${field.label} ${randomUUID().slice(0, 8)}`, + key: `tool_app.${randomUUID()}.${field.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`, + provider: "local_encrypted" as const, + description: `Credential for ${connection.name} (${field.configPath}).`, + }; + const secret = personalIdentity + ? await db.transaction(async (tx) => { + const vault = secretService(tx as unknown as Db); + const definition = await vault.createUserSecretDefinition(companyId, metadata, actorForSecret(actor)); + return vault.createCurrentUserSecretValue(companyId, personalIdentity.subjectUserId, + { definitionId: definition.id, value }, actorForSecret(actor)); + }) + : await secrets.create(companyId, { ...metadata, value }, actorForSecret(actor)); credentialSecretRefs.push({ secretId: secret.id, versionSelector: "latest", @@ -14063,7 +14139,7 @@ export function toolAccessService( ): Promise { let connection = await getConnectionRow(connectionId, companyId); assertSupportedConnection(connection); - await assertMcpAggregatorSetupEnabled(connection.config.sourceTemplateKey, connection.config.connectionMethodKey); + await assertExperimentalConnectorSetupEnabled(connection.config.sourceTemplateKey, connection.config.connectionMethodKey, connection.status !== "draft"); if (connection.status === "archived") throw conflict("Archived app connections cannot start sign in"); const sourceTemplateKey = @@ -16560,7 +16636,7 @@ export function toolAccessService( if (!app || app.availability?.available === false) throw notFound("App not found"); const method = connectionMethodFor(app, methodKey); - await assertMcpAggregatorSetupEnabled(app.slug, method.key); + await assertExperimentalConnectorSetupEnabled(app.slug, method.key); if (method.transport !== "mcp_remote" || !method.defaults?.serverUrl) { throw unprocessable( "This app method does not use a hosted remote MCP endpoint", @@ -17358,7 +17434,7 @@ export function toolAccessService( if (transport === "mcp_remote") await assertRemoteConnectionEndpointsAllowed(config); if (transport === "local_stdio") await stdioTemplateId(companyId, config); - assertLocalStdioCanBeEnabled(transport, input.enabled ?? false); + assertLocalStdioCanBeEnabled(transport, input.enabled ?? false, config); await assertGoogleSheetsSpreadsheetOwnership(companyId, config); if (applicationId) { const app = await assertApplication(companyId, applicationId); @@ -18282,6 +18358,7 @@ export function toolAccessService( assertLocalStdioCanBeEnabled( existing.transport, input.enabled ?? existing.enabled, + config, ); await assertGoogleSheetsSpreadsheetOwnership(existing.companyId, config, { excludeConnectionId: existing.id, diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 0be75fd3c79..f434cc4d656 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -1,3 +1,4 @@ +import { COGNEE_STDIO_TEMPLATE, cogneeCloudUrl, callCogneeCloud } from "./cognee-connection.js"; import { HttpError } from "../errors.js"; import { claimSlackRateLimitRetry } from "./connectors/slack-retry.js"; import { resolveSlackTaskAuthority } from "./connectors/slack-authority.js"; @@ -423,6 +424,7 @@ const BUILTIN_LOCAL_STDIO_RUNTIME_TEMPLATES: Record< string, Omit > = { + "paperclip.cognee-cloud": COGNEE_STDIO_TEMPLATE, "paperclip.google-sheets": { command: "paperclip-google-sheets-mcp-server", args: [], @@ -4097,10 +4099,10 @@ export function createToolGatewayService( connection, grant, grantRef, - // OAuth grants declare their canonical oauth.* path. Treating - // this header projection as a generic credentials.* binding loses - // the personal secret declaration created by the OAuth callback. - grantRef.configPath.startsWith("oauth.") + // Personal grants resolve against their declared vault path. API-key + // paths already include credentials.* or headers.*; prepending again + // breaks the declaration just as it does for OAuth token paths. + grant.kind === "user" || grantRef.configPath.startsWith("oauth.") ? grantRef.configPath : `credentials.${ref.name}`, ); @@ -4936,6 +4938,12 @@ export function createToolGatewayService( ); } } + if (template.templateId === "paperclip.cognee-cloud") { + try { cogneeCloudUrl(env.COGNEE_BASE_URL ?? ""); } + catch { + throw new ToolGatewayHttpError(422, "Reconnect Cognee with the tenant API Base URL from its API Keys page.", "cognee_cloud_url_invalid"); + } + } return env; } @@ -4961,6 +4969,27 @@ export function createToolGatewayService( protocolParams?: Record; timeoutMs: number; }): Promise { + if (input.template.templateId === "paperclip.cognee-cloud") { + if (input.protocolMethod === "resources/list") return { resources: [] }; + if (input.protocolMethod === "prompts/list") return { prompts: [] }; + if (input.protocolMethod && input.protocolMethod !== "tools/call") { + throw stdioProtocolError("Cognee does not expose this context operation"); + } + return callCogneeCloud({ + baseUrl: input.env.COGNEE_BASE_URL ?? "", apiKey: input.env.COGNEE_API_KEY ?? "", + tool: input.entry?.toolName ?? "", parameters: asRecord(input.parameters) ?? {}, + signal: AbortSignal.timeout(input.timeoutMs), + request: async (url, init) => { + const response = await guardedRemoteHttpFetch(url, init, remoteHttpFetchOptions()); + const body = await readBoundedRemoteResponse(response); + if (!response.ok) throw new ToolGatewayHttpError(502, + `Cognee Cloud request failed (HTTP ${response.status}).`, "cognee_api_error"); + if (!body) return { status: "success" }; + try { return JSON.parse(body); } + catch { throw new ToolGatewayHttpError(502, "Cognee Cloud returned invalid JSON.", "cognee_api_error"); } + }, + }); + } if (!input.template.command) { throw new ToolGatewayHttpError( 501, @@ -5286,6 +5315,10 @@ export function createToolGatewayService( template, grant, ); + if (template.templateId === "paperclip.cognee-cloud") { + return callLocalStdioMcp({ connection: input.connection, template, env, + protocolMethod: input.method, protocolParams: input.params ?? {}, timeoutMs: DEFAULT_TOOL_TIMEOUT_MS }); + } return runtimeSupervisor.useConnectionSlot( { companyId: input.session.companyId, @@ -5784,7 +5817,7 @@ export function createToolGatewayService( function normalizeMcpToolResult( result: unknown, - transport: "mcp_http" | "local_stdio" = "mcp_http", + transport: "mcp_http" | "local_stdio" | "cognee_cloud" = "mcp_http", spawnedLocalProcess = false, sourceTemplateKey?: string | null, ) { @@ -6254,6 +6287,7 @@ export function createToolGatewayService( tool: ToolGatewayDescriptor, parameters: unknown, ms: number, + useProviderDefaultTimeout = false, ): Promise { const { entry, connection } = await resolveConnectedLocalStdioTool( session, @@ -6267,7 +6301,13 @@ export function createToolGatewayService( template, grant, ); - const result = await runtimeSupervisor.useConnectionSlot( + const invoke = () => callLocalStdioMcp({ connection, entry, template, env, parameters, + timeoutMs: useProviderDefaultTimeout && template.templateId === "paperclip.cognee-cloud" ? 60_000 : ms }); + // Cognee is a bundled HTTP client. Provider failures are tool failures, not + // crashed local processes, and must never consume slots or restart budgets. + const result = template.templateId === "paperclip.cognee-cloud" + ? await invoke() + : await runtimeSupervisor.useConnectionSlot( { companyId: session.companyId, applicationId: tool.applicationId ?? null, @@ -6286,18 +6326,11 @@ export function createToolGatewayService( }, async (handle) => { handle.appendLog("stdout", `calling ${entry.toolName}`); - return callLocalStdioMcp({ - connection, - entry, - template, - env, - parameters, - timeoutMs: ms, - }); + return invoke(); }, ); return { - result: normalizeMcpToolResult(result, "local_stdio", true), + result: normalizeMcpToolResult(result, template.templateId === "paperclip.cognee-cloud" ? "cognee_cloud" : "local_stdio", template.templateId !== "paperclip.cognee-cloud"), }; } @@ -7111,6 +7144,7 @@ export function createToolGatewayService( args.tool, args.parameters, executionTimeoutMs, + args.timeoutMs === undefined, ) : null; if (!connectedMcpExecution) { @@ -7130,6 +7164,11 @@ export function createToolGatewayService( sensitiveMode: "redact", promptInjectionMode: "block", }); + const providerResult = asRecord(resultValidation.value); + if (providerResult?.error) { + throw new ToolGatewayHttpError(502, String(providerResult.content || providerResult.error), + "tool_error", { execution: connectedMcpExecution.execution }); + } await db .update(toolInvocations) .set({ @@ -10397,6 +10436,7 @@ export function createToolGatewayService( tool, effectiveParameters, executionTimeoutMs, + input.timeoutMs === undefined, ) : null; const result = connectedMcpExecution @@ -10427,6 +10467,17 @@ export function createToolGatewayService( promptInjectionMode: "block", }); const completedAt = new Date(); + const validatedMcpResult = connectedMcpExecution + ? asRecord(resultValidation.value) + : null; + if (validatedMcpResult?.error) { + throw new ToolGatewayHttpError( + 502, + String(validatedMcpResult.content || validatedMcpResult.error), + "tool_error", + { execution: connectedMcpExecution?.execution }, + ); + } await db .update(toolInvocations) .set({ diff --git a/ui/public/brands/apps/cognee.png b/ui/public/brands/apps/cognee.png new file mode 100644 index 00000000000..dc52c882771 Binary files /dev/null and b/ui/public/brands/apps/cognee.png differ diff --git a/ui/public/brands/apps/honcho.svg b/ui/public/brands/apps/honcho.svg new file mode 100644 index 00000000000..e23c9e3605c --- /dev/null +++ b/ui/public/brands/apps/honcho.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/public/brands/apps/manifest.json b/ui/public/brands/apps/manifest.json index 683d8ad1fbf..ec441760c43 100644 --- a/ui/public/brands/apps/manifest.json +++ b/ui/public/brands/apps/manifest.json @@ -491,6 +491,30 @@ "provider": "Fireflies", "catalogVisible": true, "localAsset": "/brands/apps/fireflies.svg" + }, + { + "slug": "cognee", + "provider": "Cognee", + "catalogVisible": true, + "localAsset": "/brands/apps/cognee.png" + }, + { + "slug": "honcho", + "provider": "Honcho", + "catalogVisible": true, + "localAsset": "/brands/apps/honcho.svg" + }, + { + "slug": "supermemory", + "provider": "Supermemory", + "catalogVisible": true, + "localAsset": "/brands/apps/supermemory.png" + }, + { + "slug": "zep", + "provider": "Zep", + "catalogVisible": true, + "localAsset": "/brands/apps/zep.png" } ] } diff --git a/ui/public/brands/apps/supermemory.png b/ui/public/brands/apps/supermemory.png new file mode 100644 index 00000000000..ec2bcb4ffb2 Binary files /dev/null and b/ui/public/brands/apps/supermemory.png differ diff --git a/ui/public/brands/apps/zep.png b/ui/public/brands/apps/zep.png new file mode 100644 index 00000000000..609bc94ec37 Binary files /dev/null and b/ui/public/brands/apps/zep.png differ diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index c456e12a3b1..d1ef67d0329 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -1,4 +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"; @@ -37,6 +38,7 @@ import type { import { aiConnectionMetadataSchema, isRemoteMcpConnectorId, + isMemoryConnectorId, isRemoteMcpConnectorMethod, connectionMethodAcceptsCustomerOAuthClient, connectionMethodRequiresConfiguration, @@ -537,6 +539,7 @@ export function ConnectionSetupFlow(props: ConnectionSetupFlowProps = {}) { 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"); const draftId = useMemo(() => { @@ -546,12 +549,16 @@ export function ConnectionSetupFlow(props: ConnectionSetupFlowProps = {}) { return null; }, [interactionId, selectedCompanyId, source]); const existingId = props.configuredConnection?.id || searchParams.get("resume") || searchParams.get("reconnect") || draftId; - const lookup = Boolean(existingId && (!source || isRemoteMcpConnectorId(source))); + const lookup = Boolean(existingId && (!source || isRemoteMcpConnectorId(source) || isMemoryConnectorId(source))); const existing = useQuery({ queryKey: ["tools", "connection", existingId], queryFn: () => toolsApi.getConnection(existingId!), enabled: lookup }); const provider = source || existing.data?.config?.sourceTemplateKey; const method = searchParams.get("method") || existing.data?.config?.connectionMethodKey; if (lookup && existing.isPending) return

Loading connection…

; if (lookup && existing.isError) return

Could not load this connection. Your saved access and credentials have not changed.

; + if (isMemoryConnectorId(provider) && !(existing.data && existing.data.status !== "draft" && existing.data.config?.sourceTemplateKey === provider)) { + if (!memory.loaded) return

Loading connection settings…

; + if (!memory.enabled) return

Enable memory connectors in Settings → Experimental to set up this connection.

; + } if (!props.byoOnly && (props.credentialSource ?? "paperclip_vault") === "paperclip_vault" && isRemoteMcpConnectorId(provider) && (!method || isRemoteMcpConnectorMethod(provider, method))) { if (!aggregators.loaded) return

Loading connection settings…

; @@ -588,6 +595,7 @@ function StandardConnectionSetupFlow({ const routeParams = useParams<{ appKey?: string }>(); const { selectedCompany, selectedCompanyId } = useCompany(); const { enabled: chatConnectorsEnabled } = useChatConnectorsEnabled(); + const { enabled: memoryConnectorsEnabled } = useMemoryConnectorsEnabled(); const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToast(); const [searchParams] = useSearchParams(); @@ -966,12 +974,12 @@ function StandardConnectionSetupFlow({ // Use the same visible catalog for cards and every branded URL shortcut. // Generic custom URLs remain usable without selecting a hidden provider. const visibleGalleryApps = useMemo( - () => (galleryQuery.data?.apps ?? []).filter((app) => + () => (galleryQuery.data?.apps ?? []).filter((app) => memoryConnectorsEnabled || !isMemoryConnectorId(app.slug)).filter((app) => chatConnectorsEnabled || !app.methods.some((method) => method.transport === "chat_sdk") || appSupportsToolCatalogSetup(app), ), - [galleryQuery.data, chatConnectorsEnabled], + [galleryQuery.data, chatConnectorsEnabled, memoryConnectorsEnabled], ); const fullRequestedDefinition = requestedAppKey ? getConnectableAppDefinition(requestedAppKey) @@ -3426,7 +3434,7 @@ function KeyStep({ : methods; const fields = (method?.credentialFields ?? []).map((field) => ({ ...field, - configPath: credentialConfigPath(field), + configPath: credentialConfigPath(field, method), helpUrl: method?.consoleLinks?.keys ?? method?.consoleLinks?.docs ?? "", })); const vercelReview = method?.credentialSources?.vercelConnect ?? null; @@ -3714,12 +3722,12 @@ function KeyStep({ {credentialFieldLabel(entry.name, field.label, fields.length)} onChange({ ...values, [field.configPath]: e.target.value })} - placeholder="••••••••••••••••" + placeholder={field.type === "text" && field.secret === false ? field.placeholder : "••••••••••••••••"} className="mt-2 h-11 font-mono" /> {field.helpUrl && ( diff --git a/ui/src/hooks/useMemoryConnectorsEnabled.ts b/ui/src/hooks/useMemoryConnectorsEnabled.ts new file mode 100644 index 00000000000..7eab02d38a8 --- /dev/null +++ b/ui/src/hooks/useMemoryConnectorsEnabled.ts @@ -0,0 +1,12 @@ +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 useMemoryConnectorsEnabled() { + const query = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); + return { enabled: !query.isError && query.data?.enableMemoryConnectors === true, loaded: query.isFetched }; +} diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index c965a20878e..b8a433c97ce 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -80,6 +80,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { enableApps: true, enableChatConnectors: false, enableMcpAggregators: false, + enableMemoryConnectors: false, enablePipelines: false, enableCases: false, enableAgentChat: false, @@ -211,6 +212,19 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) expect(container.textContent).not.toContain("Show the Apps navigation"); }); + it("defaults memory connectors off and persists an explicit toggle in both directions", async () => { + await renderPage(); + const selector = 'button[aria-label="Toggle memory connectors experimental setting"]'; + expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe("false"); + expect(container.textContent).toContain("Existing connections keep running."); + for (const enabled of [true, false]) { + await act(() => container.querySelector(selector)!.click()); + await flushReact(); + expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({ enableMemoryConnectors: enabled }); + expect(container.querySelector(selector)?.getAttribute("aria-checked")).toBe(String(enabled)); + } + }); + it("defaults MCP aggregators off and persists an explicit toggle in both directions", async () => { await renderPage(); const selector = 'button[aria-label="Toggle MCP aggregators experimental setting"]'; diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index b334c783d54..a179b09a3b9 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -444,6 +444,18 @@ export function InstanceExperimentalSettings() { ariaLabel="Toggle MCP aggregators experimental setting" /> + toggleMutation.mutate({ enableMemoryConnectors: checked })} + disabled={toggleMutation.isPending} + settingKey="enableMemoryConnectors" + managed={managedKeys.enableMemoryConnectors} + ariaLabel="Toggle memory connectors experimental setting" + /> + { expect(finishAppMock).not.toHaveBeenCalled(); }); + it.each(["mem0", "zep", "supermemory", "cognee", "honcho"])("blocks direct %s setup while memory connectors are off", async (provider) => { + mockSearch.value = `source=${provider}`; + await render(); + expect(container.textContent).toContain("Enable memory connectors"); + expect(connectAppMock).not.toHaveBeenCalled(); + expect(startOAuthMock).not.toHaveBeenCalled(); + }); + + it("allows an existing memory connection to reconnect while setup is hidden", async () => { + mockSearch.value = "source=mem0&reconnect=existing-memory"; + getConnectionMock.mockResolvedValue({ id: "existing-memory", status: "active", config: { sourceTemplateKey: "mem0" } }); + await render(); + expect(getConnectionMock).toHaveBeenCalledWith("existing-memory"); + 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) => { mockSearch.value = `source=${provider}`; await render(); diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index 895788d0f1d..14cdce1326d 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -200,6 +200,25 @@ describe("Connectors landing page", () => { expect(container.textContent).not.toContain("Paused"); }); + it("hides cached memory connectors until enabled and preserves saved MCP connections", async () => { + const providers = ["mem0", "zep", "supermemory", "cognee", "honcho"]; + 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, { enableMemoryConnectors: true }); }); + 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: "mem0", connectionMethodKey: "mcp" }, transport: "mcp_remote" })] }); + client.setQueryData(queryKeys.tools.applications("company-1"), { applications: [application({ id: "saved-app", name: "Mem0", metadata: { sourceTemplateKey: "mem0" } })] }); + client.setQueryData(queryKeys.instance.experimentalSettings, { enableMemoryConnectors: false }); + }); + await flushReact(); + expect(container.textContent).toContain("Mem0"); + 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 () => { const providers = ["zapier", "arcade", "composio", "executor"]; listGalleryMock.mockResolvedValue({ apps: [...providers, "notion"].map(getAppStoreDefinition) }); diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index 4c1d66e3c80..945935648d4 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -20,12 +20,14 @@ import type { ToolApplication, ToolConnection } from "@paperclipai/shared"; import { getAppDefinitionForUrl, isRemoteMcpConnectorId, + isMemoryConnectorId, getAppStoreDefinition, isToolConnectionAttentionHealth, aiSubscriptionNeedsIsolatedLogin, } from "@paperclipai/shared"; 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"; @@ -283,6 +285,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne const { selectedCompanyId } = useCompany(); const { enabled: chatConnectorsEnabled } = useChatConnectorsEnabled(); const { enabled: mcpAggregatorsEnabled } = useMcpAggregatorsEnabled(); + const { enabled: memoryConnectorsEnabled } = useMemoryConnectorsEnabled(); const { setBreadcrumbs } = useBreadcrumbs(); const [query, setQuery] = useState(""); const [connectionToRemove, setConnectionToRemove] = @@ -364,6 +367,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne const gallery = ( (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 ( diff --git a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx index e1480568308..cd1291c2ba0 100644 --- a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx +++ b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx @@ -291,7 +291,7 @@ function ReconnectForm({ : null; const fields = (method?.credentialFields ?? []).map((field) => ({ ...field, - configPath: credentialConfigPath(field), + configPath: credentialConfigPath(field, method), helpUrl: method?.consoleLinks?.keys ?? method?.consoleLinks?.docs ?? "", })); const [values, setValues] = useState>({}); diff --git a/ui/storybook/stories/memory-connectors.stories.tsx b/ui/storybook/stories/memory-connectors.stories.tsx new file mode 100644 index 00000000000..c5eae9e3018 --- /dev/null +++ b/ui/storybook/stories/memory-connectors.stories.tsx @@ -0,0 +1,150 @@ +import { useEffect, useMemo, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { getConnectableAppDefinition, MEMORY_CONNECTOR_IDS, INSTANCE_FEATURE_KEYS, instanceExperimentalSettingsSchema, type MemoryConnectorId } from "@paperclipai/shared"; +import { queryKeys } from "@/lib/queryKeys"; +import { ConnectionSetupCompletionScreen, ConnectionSetupFlow, OAuthConnectStateScreen } from "@/features/connections/ConnectionSetupFlow"; +import { ConnectorCard } from "@/pages/apps/Browse"; +import { InstanceExperimentalSettings } from "@/pages/InstanceExperimentalSettings"; +import { Button } from "@/components/ui/button"; + +const COMPANY = "company-storybook"; +const apps = MEMORY_CONNECTOR_IDS.map(slug => getConnectableAppDefinition(slug)!); + +type Scenario = "catalog" | "setup" | "disabled" | "settings" | "rejected" | "waiting" | "oauth-error" | "complete"; +function MemoryReview({ provider = "mem0", scenario = "catalog" }: { provider?: MemoryConnectorId; scenario?: Scenario }) { + const [selection, setSelection] = useState(scenario === "catalog" || scenario === "settings" ? null : provider); + const [completed, setCompleted] = useState(scenario === "complete"); + const [ready, setReady] = useState(false); + const [failure, setFailure] = useState(scenario === "rejected"); + const [signIn, setSignIn] = useState<{ complete: () => void } | null>(null); + const [identity, setIdentity] = useState("Just me"); + const [availableTo, setAvailableTo] = useState("Any agent"); + const client = useMemo(() => { + const q = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, refetchOnMount: false } } }); + q.setQueryData(queryKeys.instance.experimentalSettings, { ...instanceExperimentalSettingsSchema.parse({}), enableMemoryConnectors: scenario !== "disabled" }); + q.setQueryData(queryKeys.apps.gallery(COMPANY), { apps, capabilities: { canSetCompanyInstall: true, companyInstallReason: null } }); + q.setQueryData(queryKeys.health, { status: "ok", deploymentMode: "local_trusted", hiddenSettings: INSTANCE_FEATURE_KEYS.filter(k => k !== "enableMemoryConnectors").map(k => `instance.experimental.${k}`) }); + return q; + }, [scenario]); + + useEffect(() => { + const original = window.fetch; + const fixture: typeof window.fetch = async (input, init) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, location.origin); + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + // The OAuth flow waits for fresh identity reads (isFetchedAfterMount). + // Let these queries actually fetch instead of relying on seeded cache data. + if (url.pathname === `/api/companies/${COMPANY}/tools/applications`) return Response.json({ applications: [] }); + if (url.pathname === `/api/companies/${COMPANY}/tools/connections`) return Response.json({ connections: [] }); + if (url.pathname === "/api/instance/settings/experimental" && method === "PATCH") { + const settings = { ...client.getQueryData(queryKeys.instance.experimentalSettings), ...JSON.parse(String(init?.body ?? "{}")) }; + client.setQueryData(queryKeys.instance.experimentalSettings, settings); + return Response.json(settings); + } + if (url.pathname === `/api/companies/${COMPANY}/tools/apps/connect`) { + if (failure) return Response.json({ error: "The provider rejected this connection. Check your credentials or account access, then try again." }, { status: 422 }); + const body = JSON.parse(String(init?.body ?? "{}")); + const app = getConnectableAppDefinition(body.galleryKey)!; + setIdentity(body.grantKind === "user" ? "Just me" : "Any human in the organization"); + if (app.methods[0]?.auth === "oauth") { + // Keep the production mutation mounted while the reviewer walks the + // simulated provider handoff. No popup, token, or provider request. + await new Promise((resolve) => setSignIn({ complete: resolve })); + } + // Return the post-auth discovery fixture after the simulated handoff. + return Response.json({ connectionId: "memory-review", application: { id: "memory-review-app", name: app.name }, + connection: { id: "memory-review", name: app.name, config: { sourceTemplateKey: app.slug }, status: "active" }, + catalog: [], actions: { readOnly: [{ catalogEntryId: "recall", toolName: "recall", riskLevel: "read" }], canMakeChanges: [{ catalogEntryId: "remember", toolName: "remember", riskLevel: "write" }] }, suggestedDefaults: {}, auth: null }); + } + if (url.pathname === `/api/companies/${COMPANY}/tools/apps/memory-review/finish`) { + const body = JSON.parse(String(init?.body ?? "{}")); + setAvailableTo(body.access === "all_agents" ? "Any agent" : `${body.access?.agentIds?.length ?? 0} selected agents`); + return Response.json({ connectionId: "memory-review", installs: [] }); + } + if (url.pathname.startsWith("/api/tool-connections/memory-review/")) return Response.json({ connectionId: "memory-review", installs: [] }); + if (url.pathname.startsWith(`/api/companies/${COMPANY}/tools/apps/`)) return Response.json({ oauth: { metadataFound: true, registrationAdvertised: true }, endpointReachable: true }); + return original(input, init); + }; + window.fetch = fixture; + setReady(true); + return () => { if (window.fetch === fixture) window.fetch = original; }; + }, [client, failure]); + const app = selection ? getConnectableAppDefinition(selection)! : null; + return +
+

Storybook simulation · Production catalog, setup, and experimental controls. Use fake credentials. Sign-in and tool discovery are simulated; no provider calls or secret storage.

+ {scenario === "settings" ? :
+ {completed && app ? { setCompleted(false); setSelection(null); }} /> + : (scenario === "waiting" || scenario === "oauth-error") && app ? setSelection(null)} onBack={() => setSelection(null)} onCancel={() => setSelection(null)} /> + : selection && ready ? <> + + {signIn && app ?
+

Storybook sign-in simulation. Continue to simulate provider approval and return to Paperclip.

+ { signIn.complete(); setSignIn(null); }} onBack={() => { setSignIn(null); setSelection(null); }} onCancel={() => { setSignIn(null); setSelection(null); }} /> +
: null} + + :
{apps.map(entry => setSelection(entry.slug as MemoryConnectorId)} onRequestRemove={() => {}} />)}
} + {selection && !completed && scenario !== "disabled" &&
} +
} +
+
; +} +const meta = { title: "Apps/Connections/Memory", component: MemoryReview, parameters: { layout: "fullscreen" } } satisfies Meta; +export default meta; +type Story = StoryObj; +export const CatalogAndSetup: Story = { args: { scenario: "catalog" } }; +export const ExperimentalToggle: Story = { args: { scenario: "settings" } }; +export const DisabledSetup: Story = { args: { scenario: "disabled" } }; +export const Mem0: Story = { args: { scenario: "setup", provider: "mem0" } }; +export const Zep: Story = { args: { scenario: "setup", provider: "zep" } }; +export const Supermemory: Story = { args: { scenario: "setup", provider: "supermemory" } }; +export const Cognee: Story = { args: { scenario: "setup", provider: "cognee" } }; +export const Honcho: Story = { args: { scenario: "setup", provider: "honcho" } }; +export const RejectedCredentials: Story = { args: { scenario: "rejected" } }; +export const WaitingForSignIn: Story = { args: { scenario: "waiting", provider: "zep" } }; +export const NarrowSetup: Story = { args: { scenario: "setup", provider: "cognee" }, globals: { viewport: { value: "mobile1", isRotated: false } } }; + +// Drive the real access step so the credential designs are visible immediately. +// No internal component state is patched and all network replies remain fixtures. +const showCredentialFields: Story["play"] = async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Access renders before the selected provider finishes initializing. Clicking + // its disabled button at that point silently does nothing in userEvent. + const continueButton = await canvas.findByRole("button", { name: "Save and continue" }); + await waitFor(() => expect(continueButton).toBeEnabled()); + await userEvent.click(continueButton); + await expect(await canvas.findByRole("button", { name: "Connect" })).toBeVisible(); +}; +export const Mem0ApiKey: Story = { args: { scenario: "setup", provider: "mem0" }, play: showCredentialFields }; +export const CogneeApiKeyAndTenant: Story = { args: { scenario: "setup", provider: "cognee" }, play: showCredentialFields }; +export const HonchoApiKey: Story = { args: { scenario: "setup", provider: "honcho" }, play: showCredentialFields }; +export const RejectedMem0ApiKey: Story = { + args: { scenario: "rejected", provider: "mem0" }, + play: async (context) => { + await showCredentialFields(context); + const canvas = within(context.canvasElement); + await userEvent.type(canvas.getByLabelText("Your Mem0 key"), "storybook-invalid-key"); + await userEvent.click(canvas.getByRole("button", { name: "Connect" })); + await expect(await canvas.findByText(/The provider rejected this connection/)).toBeVisible(); + }, +}; +export const ZepSignInFailed: Story = { args: { scenario: "oauth-error", provider: "zep" } }; +export const SupermemorySignInFailed: Story = { args: { scenario: "oauth-error", provider: "supermemory" } }; +export const ConnectedAllToolsAllowed: Story = { args: { scenario: "complete", provider: "mem0" } }; +export const NarrowCogneeCredentials: Story = { args: { scenario: "setup", provider: "cognee" }, play: showCredentialFields, globals: { viewport: { value: "mobile1", isRotated: false } } }; + +const walkOAuthSetup: Story["play"] = async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const providerName = getConnectableAppDefinition(args.provider ?? "zep")!.name; + const continueButton = await canvas.findByRole("button", { name: `Continue to ${providerName}` }); + await waitFor(() => expect(continueButton).toBeEnabled()); + await userEvent.click(continueButton); + await expect(await canvas.findByText(/Storybook sign-in simulation/)).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: `Continue to ${providerName}` })); + await expect(await canvas.findByRole("heading", { name: `${providerName} is ready.` })).toBeVisible(); + await expect(canvas.getByText("All tools allowed")).toBeVisible(); +}; +export const ZepCompleteWalkthrough: Story = { args: { scenario: "setup", provider: "zep" }, play: walkOAuthSetup }; +export const SupermemoryCompleteWalkthrough: Story = { args: { scenario: "setup", provider: "supermemory" }, play: walkOAuthSetup };