Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/hooks/mcp-proxy.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
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`);
}
Expand Down Expand Up @@ -74,6 +122,12 @@ async function forward(message: JsonRpcMessage, apiKey: string): Promise<void> {

function main(): void {
const apiKey = getApiKeyValue();
let repoContainerTag: string | null = null;
try {
repoContainerTag = getProjectTag(process.cwd());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward tag-selection variables to the MCP child

When Codex is started with SUPERMEMORY_ISOLATE_WORKTREES=true or SUPERMEMORY_REPO_TAG, this call cannot observe the setting because the installed MCP entry in src/cli.ts allow-lists only SUPERMEMORY_CODEX_API_KEY in env_vars. Codex's mcp add --help describes --env as the variables set when launching a stdio server, and with Codex 0.144.0-alpha.4 I confirmed an unlisted host variable is absent from the child. The hooks therefore write to the requested override/isolated container while MCP searches and saves use a different generated tag; add the tag-selection variables to the installed server's env_vars.

Useful? React with 👍 / 👎.

} catch {
repoContainerTag = null;
}
let queue = Promise.resolve();
const lines = createInterface({ input: process.stdin });

Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</supermemory-recall>`;
}

Expand Down
83 changes: 82 additions & 1 deletion test/unit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ──────────────────────────────────────
Expand Down