Skip to content
Merged
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
83 changes: 83 additions & 0 deletions services/server/scripts/mcp/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import test from "node:test";

import { McpAuthError, normalizeScope, resolveAuth } from "../auth.js";

const TOKEN = "test-sidecar-token-0123456789";
const DELEGATE_KEY = "a".repeat(64);
const ACCOUNT_ID = `0x${"b".repeat(64)}`;
const SERVER_URL = "http://relayer.invalid";

function mcpHeaders(overrides: Record<string, string | undefined> = {}): Headers {
const base: Record<string, string | undefined> = {
authorization: `Bearer ${DELEGATE_KEY}`,
"x-memwal-account-id": ACCOUNT_ID,
"x-memwal-internal-sidecar-token": TOKEN,
...overrides,
};
const h = new Headers();
for (const [k, v] of Object.entries(base)) {
if (v !== undefined) h.set(k, v);
}
return h;
}

test("resolveAuth rejects a caller that cannot prove it is the relayer", async () => {
process.env.SIDECAR_AUTH_TOKEN = TOKEN;

await assert.rejects(
() => resolveAuth(mcpHeaders({ "x-memwal-internal-sidecar-token": undefined }), SERVER_URL),
(err: unknown) => {
assert.ok(err instanceof McpAuthError, `expected McpAuthError, got ${err}`);
assert.equal(err.status, 401);
return true;
}
);
});

test("resolveAuth reads the OAuth scope once the origin is verified", async () => {
process.env.SIDECAR_AUTH_TOKEN = TOKEN;

const { session } = await resolveAuth(
mcpHeaders({ "x-memwal-internal-oauth-scope": "memwal:read" }),
SERVER_URL
);

assert.equal(session.oauthScope, "memwal:read");
});

test("normalizeScope is order- and duplicate-insensitive", () => {
// The session key embeds this, so two spellings of the same grant must not
// open two distinct sessions.
assert.equal(
normalizeScope("memwal:write memwal:read"),
normalizeScope("memwal:read memwal:write")
);
assert.equal(normalizeScope("memwal:read memwal:read"), "memwal:read");
assert.equal(normalizeScope(" memwal:read memwal:write "), "memwal:read memwal:write");
});

test("normalizeScope collapses an absent or blank scope to the empty string", () => {
assert.equal(normalizeScope(undefined), "");
assert.equal(normalizeScope(""), "");
assert.equal(normalizeScope(" "), "");
});

test("sessionKey is stable across scope orderings but differs by granted scope", async () => {
process.env.SIDECAR_AUTH_TOKEN = TOKEN;

const keyFor = async (scope: string) =>
(await resolveAuth(mcpHeaders({ "x-memwal-internal-oauth-scope": scope }), SERVER_URL))
.sessionKey;

assert.equal(
await keyFor("memwal:write memwal:read"),
await keyFor("memwal:read memwal:write"),
"reordering the same grant must not fork the session"
);
assert.notEqual(
await keyFor("memwal:read memwal:write"),
await keyFor("memwal:read"),
"a narrower grant must not reuse a wider grant's session"
);
});
4 changes: 4 additions & 0 deletions services/server/scripts/mcp/__tests__/instructions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* IMPORTANT: env vars MUST be set BEFORE importing `mountMcpRoutes` because
* the rate limiter is constructed at module-load time.
*/
process.env.SIDECAR_AUTH_TOKEN ??= "instructions-test-sidecar-token";
process.env.MCP_MAX_TOTAL_SESSIONS = "100";
process.env.MCP_MAX_SESSIONS_PER_IP = "100";
process.env.MCP_MAX_NEW_SESSIONS_PER_IP_PER_MIN = "100";
Expand Down Expand Up @@ -81,6 +82,9 @@ async function initialize(): Promise<Record<string, any>> {
authorization: `Bearer ${randomBytes(32).toString("hex")}`,
"x-memwal-account-id": "0x" + randomBytes(32).toString("hex"),
"x-forwarded-for": "203.0.113.7",
// Stands in for the relayer, which proves its origin with the
// sidecar shared secret before any internal header is honoured.
"x-memwal-internal-sidecar-token": process.env.SIDECAR_AUTH_TOKEN!,
},
body: JSON.stringify({
jsonrpc: "2.0",
Expand Down
117 changes: 114 additions & 3 deletions services/server/scripts/mcp/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* IMPORTANT: env vars MUST be set BEFORE importing `mountMcpRoutes` because
* the rate limiter is constructed at module-load time.
*/
process.env.SIDECAR_AUTH_TOKEN ??= "integration-test-sidecar-token";
process.env.MCP_MAX_TOTAL_SESSIONS = "100";
process.env.MCP_MAX_SESSIONS_PER_IP = "100";
// Tight burst cap so the test can trip it in 3 calls.
Expand Down Expand Up @@ -51,6 +52,23 @@ function fakeCreds(): { bearer: string; accountId: string } {

let server: Server;
let baseUrl: string;
const apiLog: Array<{ path: string; status: number }> = [];

/** Wait for the bridge's own upstream request to land on the /api mount. */
async function waitForApi(
pred: (entry: { path: string; status: number }) => boolean,
timeoutMs = 5000,
): Promise<{ path: string; status: number }> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const hit = apiLog.find(pred);
if (hit) return hit;
if (Date.now() > deadline) {
throw new Error(`no matching /api request within ${timeoutMs}ms; saw ${JSON.stringify(apiLog)}`);
}
await new Promise((r) => setTimeout(r, 25));
}
}

before(async () => {
const app = express();
Expand All @@ -66,6 +84,30 @@ before(async () => {
// routes. Mount the real handlers under /api as well so the official
// stdio bridge can exercise its production URLs without a proxy stub.
const publicApi = express.Router();
// Record what the bridge actually got back. A tools/list assertion cannot
// tell upstream from the bridge's local auth-required fallback, because
// that fallback advertises the same tool names — so the only honest signal
// that the upstream connection worked is the response status here.
publicApi.use((req, res, next) => {
// Capture the status as headers are written, not on "finish": an SSE
// response stays open, so "finish" never fires for /mcp/sse.
const writeHead = res.writeHead.bind(res);
res.writeHead = ((...args: Parameters<typeof writeHead>) => {
apiLog.push({ path: req.path, status: args[0] as number });
return writeHead(...args);
}) as typeof res.writeHead;
next();
});
// Stand in for the Rust relayer's `apply_internal_headers`. In production
// /api/mcp/* is served by the proxy, which states the sidecar token and the
// caller's granted scope on every forwarded request. Without this the
// official bridge cannot authenticate against the sidecar at all, and the
// test would silently fall back to the bridge's local tool list.
publicApi.use((req, _res, next) => {
req.headers["x-memwal-internal-sidecar-token"] = INTERNAL_TOKEN;
req.headers["x-memwal-internal-oauth-scope"] = "memwal:read memwal:write";
next();
});
mountMcpRoutes(publicApi, { relayerUrl: "http://localhost:1" });
app.use("/api", publicApi);
await new Promise<void>((resolve) => {
Expand All @@ -92,20 +134,28 @@ function initializeBody(id: number) {
});
}

// The relayer proves it is the relayer with the sidecar shared secret; every
// request that expects to get past `resolveAuth` has to carry it.
const INTERNAL_TOKEN_HEADER = "x-memwal-internal-sidecar-token";
const INTERNAL_TOKEN = process.env.SIDECAR_AUTH_TOKEN!;

function mcpHeaders(opts: {
bearer: string;
accountId: string;
xff: string;
sessionId?: string;
scope?: string;
}): Record<string, string> {
const h: Record<string, string> = {
"content-type": "application/json",
accept: "application/json, text/event-stream",
authorization: `Bearer ${opts.bearer}`,
"x-memwal-account-id": opts.accountId,
"x-forwarded-for": opts.xff,
[INTERNAL_TOKEN_HEADER]: INTERNAL_TOKEN,
};
if (opts.sessionId) h["mcp-session-id"] = opts.sessionId;
if (opts.scope !== undefined) h["x-memwal-internal-oauth-scope"] = opts.scope;
return h;
}

Expand All @@ -114,6 +164,7 @@ async function postInit(opts: {
accountId: string;
xff: string;
sessionId?: string;
scope?: string;
id?: number;
}): Promise<{ status: number; sessionId: string | null; bodyText: string }> {
const res = await fetch(`${baseUrl}/mcp`, {
Expand Down Expand Up @@ -142,6 +193,7 @@ async function openSse(opts: {
authorization: `Bearer ${opts.bearer}`,
"x-memwal-account-id": opts.accountId,
"x-forwarded-for": opts.xff,
[INTERNAL_TOKEN_HEADER]: INTERNAL_TOKEN,
},
});
assert.equal(response.status, 200);
Expand Down Expand Up @@ -225,6 +277,46 @@ test("reusing mcp-session-id under a different bearer returns 403", async () =>
assert.match((json as any).error.message, /does not match authenticated caller/);
});

test("reusing mcp-session-id under a narrower scope returns 403", async () => {
// The tool set is bound at session-open time, so a session opened with
// write scope keeps its write tools registered. If the session key ignores
// scope, a later read-only request routes into that write-capable
// transport and the fail-closed guarantee stops holding after init.
const xff = "192.0.2.80";
const creds = fakeCreds();

const opened = await postInit({ ...creds, xff, scope: "memwal:read memwal:write" });
assert.equal(opened.status, 200);
const sid = opened.sessionId;
assert.ok(sid);

const narrowed = await fetch(`${baseUrl}/mcp`, {
method: "POST",
headers: mcpHeaders({ ...creds, xff, sessionId: sid!, scope: "memwal:read" }),
body: JSON.stringify({ jsonrpc: "2.0", id: 98, method: "ping" }),
});

assert.equal(narrowed.status, 403, `expected 403, got ${narrowed.status}`);
});

test("reusing mcp-session-id with the scope header dropped returns 403", async () => {
const xff = "192.0.2.81";
const creds = fakeCreds();

const opened = await postInit({ ...creds, xff, scope: "memwal:read memwal:write" });
assert.equal(opened.status, 200);
const sid = opened.sessionId;
assert.ok(sid);

const dropped = await fetch(`${baseUrl}/mcp`, {
method: "POST",
headers: mcpHeaders({ ...creds, xff, sessionId: sid! }),
body: JSON.stringify({ jsonrpc: "2.0", id: 97, method: "ping" }),
});

assert.equal(dropped.status, 403, `expected 403, got ${dropped.status}`);
});

test("reusing mcp-session-id under SAME bearer is accepted (sanity check)", async () => {
const xff = "192.0.2.50";
const creds = fakeCreds();
Expand Down Expand Up @@ -258,6 +350,8 @@ test("malformed bearer returns 401, not 429 — auth still runs after rate-limit
authorization: "Bearer not-a-hex-key",
"x-memwal-account-id": "0x" + "a".repeat(64),
"x-forwarded-for": "192.0.2.60",
// Origin is verified; the malformed bearer is what must be rejected.
[INTERNAL_TOKEN_HEADER]: INTERNAL_TOKEN,
},
body: initializeBody(1),
});
Expand All @@ -276,7 +370,11 @@ test("SSE message POST is bound to the principal that opened the session", async
try {
const noAuth = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
headers: {
"content-type": "application/json",
// Origin is verified; the absent bearer is what must be rejected.
[INTERNAL_TOKEN_HEADER]: INTERNAL_TOKEN,
},
body,
});
assert.equal(noAuth.status, 401);
Expand Down Expand Up @@ -407,12 +505,25 @@ test("official stdio bridge authenticates SSE message POSTs", async (t) => {
assert(initialized.result, JSON.stringify(initialized));

send({ jsonrpc: "2.0", method: "notifications/initialized" });

// Wait for the upstream SSE BEFORE asking for tools. The bridge answers
// tools/list from its coldstart path while the connection is still in
// flight, and its local fallback advertises the same tool names as the
// relayer — so a list requested too early cannot distinguish the two.
const sse = await waitForApi((entry) => entry.path.includes("/mcp/sse"));
assert.equal(sse.status, 200, `upstream SSE was rejected: ${JSON.stringify(apiLog)}`);

send({ jsonrpc: "2.0", id: 902, method: "tools/list", params: {} });
const tools = await waitFor((message) => message.id === 902);
assert(Array.isArray(tools.result?.tools), JSON.stringify(tools));

// Require the relayer-registered tools. These appear only when the proxy
// stated a scope wide enough for `registerTools` to register them, so this
// fails if the simulated proxy stops sending either internal header.
const names = new Set(tools.result.tools.map((tool: { name?: string }) => tool.name));
assert(
tools.result.tools.some((tool: { name?: string }) => tool.name === "memwal_login"),
"bridge should receive the authenticated tools/list response",
["memwal_recall", "memwal_health", "memwal_remember"].every((n) => names.has(n)),
`bridge should receive the authenticated upstream tools/list response, got: ${[...names].join(", ")}`,
);

child.stdin.end();
Expand Down
45 changes: 45 additions & 0 deletions services/server/scripts/mcp/__tests__/internal-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import test from "node:test";

import { verifyInternalOrigin } from "../internal-auth.js";

const TOKEN = "test-sidecar-token-0123456789";

function headersWith(token?: string): Headers {
const h = new Headers();
if (token !== undefined) h.set("x-memwal-internal-sidecar-token", token);
return h;
}

test("verifyInternalOrigin accepts a request carrying the configured sidecar token", () => {
process.env.SIDECAR_AUTH_TOKEN = TOKEN;

assert.equal(verifyInternalOrigin(headersWith(TOKEN)), true);
});

test("verifyInternalOrigin rejects a request with no sidecar token header", () => {
process.env.SIDECAR_AUTH_TOKEN = TOKEN;

assert.equal(verifyInternalOrigin(headersWith(undefined)), false);
});

test("verifyInternalOrigin rejects a wrong token of the same length", () => {
process.env.SIDECAR_AUTH_TOKEN = TOKEN;
const wrong = "X".repeat(TOKEN.length);

assert.equal(wrong.length, TOKEN.length);
assert.equal(verifyInternalOrigin(headersWith(wrong)), false);
});

test("verifyInternalOrigin rejects a token that is a prefix of the real one", () => {
process.env.SIDECAR_AUTH_TOKEN = TOKEN;

assert.equal(verifyInternalOrigin(headersWith(TOKEN.slice(0, -1))), false);
});

test("verifyInternalOrigin rejects everything when SIDECAR_AUTH_TOKEN is unset", () => {
delete process.env.SIDECAR_AUTH_TOKEN;

assert.equal(verifyInternalOrigin(headersWith(TOKEN)), false);
assert.equal(verifyInternalOrigin(headersWith("")), false);
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import { createMcpServer } from "../server.js";

test("tools/list publishes safe titles and behavior annotations for every remote tool", async (t) => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = createMcpServer({} as MemWalSession);
// Full scope so every remote tool is registered — tool registration is
// scope-gated, and this test is about the annotations each tool publishes.
const server = createMcpServer({
oauthScope: "memwal:read memwal:write",
} as MemWalSession);
const client = new Client({ name: "annotations-test", version: "1.0.0" });

t.after(async () => {
Expand Down
Loading
Loading