Files
PaperClipAI/server/src/services/cognee-connection.ts
T
DottaandPaperclip 18dac1e1ef 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 <noreply@paperclip.ing>
2026-09-24 15:54:38 -05:00

116 lines
5.7 KiB
TypeScript

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<string, unknown>;
request: (url: URL, init: RequestInit) => Promise<unknown>;
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<string, string> = { "X-Api-Key": input.apiKey };
if (tenant) headers["X-Tenant-Id"] = tenant;
const args = input.parameters;
const post = async (path: string, body: Record<string, unknown> | 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 };
}