diff --git a/src/hooks/mcp-proxy.ts b/src/hooks/mcp-proxy.ts index 57e2fa1..9ad856f 100644 --- a/src/hooks/mcp-proxy.ts +++ b/src/hooks/mcp-proxy.ts @@ -1,17 +1,65 @@ import { createInterface } from "node:readline"; import { getApiKeyValue } from "../config.js"; +import { getProjectTag } from "../services/tags.js"; const MCP_URL = process.env.SUPERMEMORY_MCP_URL || "https://mcp.supermemory.ai/mcp"; const REQUEST_TIMEOUT_MS = 30_000; +const REPO_SCOPED_TOOLS = new Set([ + "search_memory", + "add_memory", + "listDocuments", + "listMemories", + "memory-graph", + "fetch-graph-data", + "save-memory", +]); + let sessionId: string | null = null; interface JsonRpcMessage { id?: string | number | null; + method?: string; + params?: unknown; [key: string]: unknown; } +// Hosted MCP omits to activeSpace; default space-scoped calls to this repo instead. +function injectRepoContainerTag( + message: JsonRpcMessage, + containerTag: string | null, +): void { + if (!containerTag || message.method !== "tools/call") return; + const params = message.params; + if (!params || typeof params !== "object" || Array.isArray(params)) return; + const record = params as Record; + if (typeof record.name !== "string" || !REPO_SCOPED_TOOLS.has(record.name)) { + return; + } + + let args = record.arguments; + let encoded = false; + if (args == null) { + record.arguments = { containerTag }; + return; + } + if (typeof args === "string") { + try { + args = JSON.parse(args) as unknown; + encoded = true; + } catch { + return; + } + } + if (!args || typeof args !== "object" || Array.isArray(args)) return; + const body = args as Record; + if (typeof body.containerTag === "string" && body.containerTag.trim()) return; + + body.containerTag = containerTag; + record.arguments = encoded ? JSON.stringify(body) : body; +} + function send(message: unknown): void { process.stdout.write(`${JSON.stringify(message)}\n`); } @@ -74,6 +122,12 @@ async function forward(message: JsonRpcMessage, apiKey: string): Promise { function main(): void { const apiKey = getApiKeyValue(); + let repoContainerTag: string | null = null; + try { + repoContainerTag = getProjectTag(process.cwd()); + } catch { + repoContainerTag = null; + } let queue = Promise.resolve(); const lines = createInterface({ input: process.stdin }); @@ -98,6 +152,7 @@ function main(): void { } try { + injectRepoContainerTag(message, repoContainerTag); await forward(message, apiKey); } catch (error) { const detail = error instanceof Error ? error.message : String(error); diff --git a/src/hooks/recall.ts b/src/hooks/recall.ts index b53e1f6..d99c554 100644 --- a/src/hooks/recall.ts +++ b/src/hooks/recall.ts @@ -56,7 +56,7 @@ function formatRecall(items: RecallItem[], containerTag: string): string { ◪ Recalled from supermemory for this prompt (relevance-ranked): ${lines.join("\n")} -When one of these shapes your answer, credit it naturally with the ◪ prefix (e.g. "◪ earlier you decided X"); if you name the source, say "from supermemory" — never "from memory". For deeper history, call the supermemory search_memory tool (containerTag: "${containerTag}"). +When one of these shapes your answer, credit it naturally with the ◪ prefix (e.g. "◪ earlier you decided X"); if you name the source, say "from supermemory" — never "from memory". For deeper history, call the supermemory search_memory tool — it defaults to this project's container (${containerTag}). Pass containerTag only to search a different space. `; } diff --git a/test/unit.mjs b/test/unit.mjs index ab7f5a6..2b9fc04 100644 --- a/test/unit.mjs +++ b/test/unit.mjs @@ -4,9 +4,10 @@ */ import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { writeFileSync, readFileSync, mkdirSync, rmSync, existsSync } from "node:fs"; +import http from "node:http"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; @@ -1149,6 +1150,86 @@ describe("hosted MCP hooks", () => { assert.equal(output.error.code, -32001); assert.match(output.error.message, /Start a new Codex task/); }); + + function runProxy(t, env, lines) { + return new Promise((resolve, reject) => { + const child = spawn("node", [proxyBin], { + env: { ...process.env, ...env }, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.on("error", reject); + child.on("close", () => + resolve(stdout.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))), + ); + for (const line of lines) child.stdin.write(`${JSON.stringify(line)}\n`); + child.stdin.end(); + }); + } + + function startStubServer(t, handler) { + return new Promise((resolve) => { + const requests = []; + const server = http.createServer((req, res) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + const record = { headers: req.headers, body }; + requests.push(record); + handler(record, res); + }); + }); + server.listen(0, "127.0.0.1", () => { + t.after(() => server.close()); + resolve({ url: `http://127.0.0.1:${server.address().port}`, requests }); + }); + }); + } + + test("injects the repo container tag when MCP tools omit it", async (t) => { + const tmpDir = makeTmpDir(); + t.after(() => rmSync(tmpDir, { recursive: true, force: true })); + const stub = await startStubServer(t, (record, res) => { + res.setHeader("Content-Type", "application/json"); + const { id } = JSON.parse(record.body); + res.end(JSON.stringify({ jsonrpc: "2.0", id, result: { ok: true } })); + }); + + await runProxy( + t, + { + HOME: tmpDir, + USERPROFILE: tmpDir, + SUPERMEMORY_CODEX_API_KEY: "sm_test_key_0123456789abcdef", + SUPERMEMORY_REPO_TAG: "repo_test_tag", + SUPERMEMORY_MCP_URL: `${stub.url}/mcp`, + }, + [ + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "search_memory", arguments: { query: "auth" } }, + }, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "whoAmI" }, + }, + ], + ); + + const forwarded = stub.requests.map((r) => JSON.parse(r.body)); + assert.equal(forwarded[0].params.arguments.containerTag, "repo_test_tag"); + assert.equal(forwarded[0].params.arguments.query, "auth"); + assert.equal(forwarded[1].params.arguments, undefined); + }); }); // ─── flush hook — Stop payload handling ──────────────────────────────────────