From 18dac1e1efa48477df83b88ae9d8dbf134438e5e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:54:38 -0700 Subject: [PATCH] feat(connections): add experimental memory providers and remote MCP access (#13942) ## Thinking Path > - Paperclip manages AI agents and their work. > - Connections give agents governed access to external tools. > - Agents need durable memory across tasks and execution environments. > - Mem0, Zep, Supermemory, Cognee, and Honcho provide hosted memory tools. > - This pull request adds their setup flows behind an experimental toggle. > - It also delivers assigned MCP tools through the native remote Codex runner. > - Operators can connect a provider once and use the same governed tools locally or in Daytona. ## Linked Issues or Issue Description **Problem or motivation** The Apps catalog lacks a complete set of memory providers. Remote native Codex agents also need access to assigned managed MCP tools without receiving provider credentials. **Proposed solution** Add five memory connectors behind the disabled-by-default Experimental memory connectors setting. Use the existing connection setup and permissions UI. Default their tools to Allowed. Preserve company boundaries, operator permission changes, provider scopes, and audit attribution. **Alternatives considered** Direct provider credentials in each sandbox would duplicate setup and bypass the managed gateway. The remote runner instead uses its existing protocol channel to call the gateway on the server. **Roadmap alignment** This maintainer-requested experiment supports the Memory / Knowledge and Connected Apps roadmap areas. It adds provider connections without introducing a separate memory UI. Related connector authoring documentation is tracked in #13692; no duplicate memory-provider implementation was found. ## What Changed - Add provider definitions, official branding, and the experimental setting for all five providers. - Use OAuth for Zep and Supermemory, API credentials for Mem0 and Honcho, and a bundled Cloud API bridge for Cognee with no runtime downloads or subprocesses. - Default memory tools to Allowed and classify destructive actions explicitly. - Fix personal remote credential resolution and propagate provider tool errors. - Relay assigned managed MCP tools to remote native Codex through the runner protocol. Recheck current authority for each call and rotate stale tool contracts. - Add 21 Storybook states and complete OAuth walkthrough fixtures. - Document provider research, sanitized tool inventory, and live local and Daytona proof. ## Verification - Passed workspace typecheck: `pnpm -r typecheck`. - Passed production build: `pnpm build`. - Full local `pnpm test:run`: 13,273 passed, with failures from process/readiness timeouts under parallel load. Reran all 16 affected suites with one worker: 456 passed, leaving two macOS `/var` versus `/private/var` path assertions. Both passed with `TMPDIR=/private/tmp`. No test failures remain unverified. Latest-head remote CI passes all 54 checks (two optional Storybook jobs skipped). Greptile is 5/5 with no unresolved findings. - Latest Cognee gateway regression: 71 passed, including public deployment without a runtime host and immediate recovery after a provider error. Bundled bridge tests: 25 passed. - Browser setup and real agent tasks exercised all five providers. Mem0, Cognee, Zep, and Honcho have successful store/retrieve proof. - Supermemory now has scoped read/write consent. Local storage and real Daytona write, document read, and semantic recall passed; indexing completion was verified before claiming success. - All five providers were exercised through a real Daytona sandbox and its native runner MCP relay. Provider credentials remained on the server. The final bundled Cognee bridge also passed a fresh Daytona store/recall run. All disposable sandboxes were removed and verified absent after testing. - Storybook is rebuilt and contains the experimental toggle, catalog, setup, permissions, and error states. The Zep and Supermemory access steps advance correctly. ## Risks - Provider OAuth scopes and plan limits remain independent of Paperclip tool permissions. An Allowed tool can still be rejected by the provider. - Providers can queue memory indexing; save acceptance does not prove that semantic recall is ready. - Remote tool contracts must stay synchronized with current connection authority. Regression tests cover revocation and stale contracts. - This change has no database migration. Existing connections remain usable when the experimental catalog toggle is disabled. ## Model Used OpenAI Codex, GPT-6 Astra (`gpt-6-astra`), with reasoning, repository edits, code execution, and browser automation. The exact context window size is not exposed in this session. Live acceptance agents used `gpt-5.6-sol`. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/connections/MEMORY.md | 373 +++++++ doc/connections/README.md | 2 + doc/connections/memory-tool-inventory.json | 950 ++++++++++++++++++ .../codex-local/src/server/codex-home.test.ts | 3 +- .../codex-local/src/server/codex-home.ts | 2 +- .../shared/src/app-definitions.generated.ts | 112 ++- packages/shared/src/app-definitions.test.ts | 10 +- packages/shared/src/app-definitions.ts | 4 +- .../shared/src/app-definitions/cognee.json | 71 ++ .../shared/src/app-definitions/honcho.json | 56 ++ packages/shared/src/app-definitions/mem0.json | 8 +- .../src/app-definitions/supermemory.json | 50 + packages/shared/src/app-definitions/zep.json | 49 + packages/shared/src/feature-catalog.ts | 7 + packages/shared/src/index.ts | 2 + packages/shared/src/memory-connectors.test.ts | 33 + packages/shared/src/memory-connectors.ts | 6 + .../shared/src/self-serve-mcp-research.json | 5 +- packages/shared/src/types/instance.ts | 2 + packages/shared/src/validators/instance.ts | 1 + scripts/ingest-app-definitions.mjs | 51 +- .../instance-settings-service.test.ts | 1 + .../src/__tests__/memory-connectors.test.ts | 72 ++ .../src/__tests__/tool-access-service.test.ts | 149 ++- .../__tests__/tool-gateway-service.test.ts | 10 +- server/src/__tests__/tool-gateway.test.ts | 152 +++ server/src/app.ts | 2 + server/src/routes/tool-access.ts | 5 +- server/src/routes/tool-gateway.ts | 10 + server/src/services/cognee-connection.ts | 115 +++ server/src/services/instance-settings.ts | 2 + .../native-runtime/assigned-mcp-tools.test.ts | 149 +++ .../native-runtime/assigned-mcp-tools.ts | 85 ++ .../native-session-executor.test.ts | 53 + .../native-runtime/native-session-executor.ts | 34 + .../native-runtime/native-session-resume.ts | 3 + .../paperclip-runner-tool-authority.test.ts | 28 + .../paperclip-runner-tool-authority.ts | 8 +- server/src/services/tool-access.ts | 139 ++- server/src/services/tool-gateway.ts | 81 +- ui/public/brands/apps/cognee.png | Bin 0 -> 32851 bytes ui/public/brands/apps/honcho.svg | 33 + ui/public/brands/apps/manifest.json | 24 + ui/public/brands/apps/supermemory.png | Bin 0 -> 1340 bytes ui/public/brands/apps/zep.png | Bin 0 -> 10546 bytes .../connections/ConnectionSetupFlow.tsx | 20 +- ui/src/hooks/useMemoryConnectorsEnabled.ts | 12 + .../InstanceExperimentalSettings.test.tsx | 14 + ui/src/pages/InstanceExperimentalSettings.tsx | 12 + ui/src/pages/apps/AppsConnect.test.tsx | 16 + ui/src/pages/apps/Browse.test.tsx | 19 + ui/src/pages/apps/Browse.tsx | 4 + .../pages/apps/app-detail/AdvancedPanel.tsx | 2 +- .../stories/memory-connectors.stories.tsx | 150 +++ 54 files changed, 3066 insertions(+), 135 deletions(-) create mode 100644 doc/connections/MEMORY.md create mode 100644 doc/connections/memory-tool-inventory.json create mode 100644 packages/shared/src/app-definitions/cognee.json create mode 100644 packages/shared/src/app-definitions/honcho.json create mode 100644 packages/shared/src/app-definitions/supermemory.json create mode 100644 packages/shared/src/app-definitions/zep.json create mode 100644 packages/shared/src/memory-connectors.test.ts create mode 100644 packages/shared/src/memory-connectors.ts create mode 100644 server/src/__tests__/memory-connectors.test.ts create mode 100644 server/src/services/cognee-connection.ts create mode 100644 server/src/services/native-runtime/assigned-mcp-tools.test.ts create mode 100644 server/src/services/native-runtime/assigned-mcp-tools.ts create mode 100644 ui/public/brands/apps/cognee.png create mode 100644 ui/public/brands/apps/honcho.svg create mode 100644 ui/public/brands/apps/supermemory.png create mode 100644 ui/public/brands/apps/zep.png create mode 100644 ui/src/hooks/useMemoryConnectorsEnabled.ts create mode 100644 ui/storybook/stories/memory-connectors.stories.tsx 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 0000000000000000000000000000000000000000..dc52c882771ffc793e2031d949d724cc8938217d GIT binary patch literal 32851 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ec2bcb4ffb2f512ba647f06dc358daf4e7b21836 GIT binary patch literal 1340 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 literal 0 HcmV?d00001 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 };