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

import { McpAuthError, 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");
});
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
16 changes: 15 additions & 1 deletion 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 @@ -92,6 +93,11 @@ 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;
Expand All @@ -104,6 +110,7 @@ function mcpHeaders(opts: {
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;
return h;
Expand Down Expand Up @@ -142,6 +149,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 @@ -258,6 +266,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 +286,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
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
58 changes: 58 additions & 0 deletions services/server/scripts/mcp/__tests__/tool-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import test, { type TestContext } from "node:test";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import type { MemWalSession } from "../auth.js";
import { createMcpServer } from "../server.js";

const WRITE_TOOLS = [
"memwal_remember",
"memwal_remember_bulk",
"memwal_analyze",
"memwal_restore",
];
const READ_TOOLS = ["memwal_recall", "memwal_health"];

async function toolNamesFor(oauthScope: string | undefined, t: TestContext): Promise<string[]> {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = createMcpServer({ oauthScope } as MemWalSession);
const client = new Client({ name: "scope-test", version: "1.0.0" });

t.after(async () => {
await client.close();
await server.close();
});

await server.connect(serverTransport);
await client.connect(clientTransport);

// With zero tools registered the SDK never declares the `tools` capability,
// so `tools/list` is an unknown method rather than an empty list. From the
// client's side both mean the same thing: no tools are available.
try {
const { tools } = await client.listTools();
return tools.map((tool) => tool.name).sort();
} catch (err) {
if (err instanceof Error && err.message.includes("Method not found")) return [];
throw err;
}
}

test("registerTools grants nothing when the relayer sent no scope", async (t) => {
assert.deepEqual(await toolNamesFor(undefined, t), []);
});

test("registerTools grants nothing when the scope is empty or whitespace", async (t) => {
assert.deepEqual(await toolNamesFor(" ", t), []);
});

test("registerTools grants only read tools for memwal:read", async (t) => {
assert.deepEqual(await toolNamesFor("memwal:read", t), [...READ_TOOLS].sort());
});

test("registerTools grants every tool for the full legacy scope", async (t) => {
assert.deepEqual(
await toolNamesFor("memwal:read memwal:write", t),
[...READ_TOOLS, ...WRITE_TOOLS].sort()
);
});
17 changes: 17 additions & 0 deletions services/server/scripts/mcp/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* =============================================================================
*/
import { MemWal } from "@mysten-incubation/memwal";
import { verifyInternalOrigin } from "./internal-auth.js";

export interface MemWalSession {
accountId: string;
Expand Down Expand Up @@ -75,17 +76,33 @@ function bytesToHex(b: Uint8Array): string {
* Resolve auth from incoming HTTP headers.
*
* Required headers:
* X-MemWal-Internal-Sidecar-Token: <SIDECAR_AUTH_TOKEN>
* Authorization: Bearer <ed25519-private-key-hex> (64 hex chars)
* X-MemWal-Account-Id: 0x<sui-object-id> (66 chars)
* Optional:
* X-MemWal-Namespace: <namespace> (default per-tool)
* X-MemWal-Internal-Oauth-Scope: <space-separated> (relayer-issued)
*
* The internal token is checked first: `x-memwal-internal-*` headers state
* decisions the relayer already made, so only the relayer may set them. An
* absent scope grants no tools — see tools/index.ts.
*
* Throws McpAuthError on missing / malformed credentials.
*/
export async function resolveAuth(
headers: Headers,
serverUrl: string
): Promise<AuthResolution> {
// Runs before anything else reads the request: `x-memwal-internal-*`
// headers carry decisions the relayer already made, so a caller that
// cannot prove it is the relayer must not be able to state them.
if (!verifyInternalOrigin(headers)) {
throw new McpAuthError(
"Request did not originate from the MemWal relayer",
401
);
}

const authHeader = headers.get("authorization");
if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) {
throw new McpAuthError(
Expand Down
36 changes: 36 additions & 0 deletions services/server/scripts/mcp/internal-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* =============================================================================
* MCP INTERNAL-ORIGIN VERIFICATION
* =============================================================================
* `x-memwal-internal-*` headers carry decisions the relayer has already made
* (currently the resolved OAuth scope). They are trusted input, so the sidecar
* must confirm they actually came from the relayer before reading any of them.
*
* `/mcp/*` is mounted BEFORE `sharedSecretAuthMiddleware` (sidecar/app.ts)
* because the `Authorization` header already carries the end user's delegate
* key, leaving no room for the sidecar's shared secret. The relayer therefore
* presents the same secret in a dedicated internal header instead.
*
* `SIDECAR_AUTH_TOKEN` is read from the environment on every call rather than
* imported from sidecar/middleware.ts, whose module-level `process.exit(1)`
* guard would fire inside unit tests that never intend to boot the sidecar.
* =============================================================================
*/
import { timingSafeEqual } from "node:crypto";

export const INTERNAL_TOKEN_HEADER = "x-memwal-internal-sidecar-token";

export function verifyInternalOrigin(headers: Headers): boolean {
const expected = process.env.SIDECAR_AUTH_TOKEN;
if (!expected) return false;

const provided = headers.get(INTERNAL_TOKEN_HEADER);
if (provided === null) return false;

const providedBuf = Buffer.from(provided);
const expectedBuf = Buffer.from(expected);
// timingSafeEqual requires equal lengths; a length mismatch is already a
// mismatch, so short-circuit rather than throwing.
return providedBuf.length === expectedBuf.length
&& timingSafeEqual(providedBuf, expectedBuf);
}
10 changes: 7 additions & 3 deletions services/server/scripts/mcp/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@ import { registerHealthTool } from "./health.js";
* relayer for SEAL encrypt/decrypt + Walrus storage.
*/
export function registerTools(server: McpServer, session: MemWalSession): void {
// Fail closed: the relayer states the granted scope explicitly on every
// forwarded request — the resolved grant for OAuth callers, and the full
// read+write scope for legacy delegate-key callers. An absent or empty
// scope therefore means the relayer never vouched for this request, so it
// grants nothing rather than everything.
const granted = new Set(session.oauthScope?.split(/\s+/).filter(Boolean));
const unrestricted = session.oauthScope === undefined;
const canRead = unrestricted || granted.has("memwal:read");
const canWrite = unrestricted || granted.has("memwal:write");
const canRead = granted.has("memwal:read");
const canWrite = granted.has("memwal:write");

if (canWrite) {
registerRememberTool(server, session);
Expand Down
9 changes: 7 additions & 2 deletions services/server/scripts/sidecar/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,13 @@ export function createSidecarApp(mode: "full" | "writer" = SIDECAR_ROUTE_MODE):
// middleware: MCP traffic is forwarded by the Rust relayer with the end-user's
// own delegate-key Bearer token in `Authorization`, NOT the sidecar's shared
// secret. The MCP layer does its own auth (parse delegate key + account id
// from request headers). These routes are reachable only from the relayer
// over localhost — same trust boundary as the rest of the sidecar.
// from request headers).
//
// Skipping the middleware does NOT mean these routes are unauthenticated:
// `resolveAuth` requires the sidecar shared secret in
// `x-memwal-internal-sidecar-token` before it will honour any
// `x-memwal-internal-*` header, so localhost reachability alone is not
// enough to claim relayer-issued privileges (GH #685).
if (mode === "full") {
mountMcpRoutes(app, {
relayerUrl: process.env.MEMWAL_RELAYER_URL ?? "http://localhost:3001",
Expand Down
Loading
Loading