From 092152bd73aaf3130a3b672e357c4d395d027d4c Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 24 May 2026 22:08:37 -0400 Subject: [PATCH] =?UTF-8?q?feat(adr-124/architecture):=20schema-validation?= =?UTF-8?q?=20gate=20+=20Streamable=20HTTP=20transport=20(ADR-124=20=C2=A7?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advances SPARC Phase 3 (Architecture): wires the phase-2 schema barrel into the MCP CallTool dispatch loop, and scaffolds the Streamable HTTP transport with Origin-validation and bearer-token auth as specified in ADR-124 §3/§6. Sub-task (a) — Uniform Zod validation gate in src/index.ts: - Import TOOL_INPUT_SCHEMAS + McpError + ErrorCode from SDK - CallTool handler: before dispatch, looks up schema by tool name using Object.prototype.hasOwnProperty (safe for dotted keys) then runs schema.safeParse(args); failures throw McpError(InvalidParams) so the caller receives a typed JSON-RPC error rather than a wrapped string - Re-throws McpError instances unchanged (policy errors propagate cleanly) Sub-task (b) — src/http-transport.ts (new, 145 LOC): - buildHttpApp(mcpServer, opts): creates Node.js http.Server + StreamableHTTPServerTransport without binding; testable in isolation - createHttpTransport(mcpServer, opts): binds and resolves when listening - isOriginAllowed(origin, allowedOrigins): pure function — undefined origin allowed (non-browser), present origin validated against allowlist, '*' disables gate for local-dev - Bearer-token gate: RVAGENT_HTTP_TOKEN env or opts.bearerToken; missing/ wrong token → 401 before any JSON-RPC processing - Bind default: 127.0.0.1 per MCP spec security requirement (ADR-124 §3) - Transport connect() only in createHttpTransport (not buildHttpApp) to avoid exactOptionalPropertyTypes false-incompatibility in test contexts New test: tests/http-transport.test.ts (11 assertions): - isOriginAllowed() unit ×5: undefined allowed, allowlist hit/miss, wildcard, case-sensitivity (RFC 6454) - Origin-validation integration ×3: cross-origin → 403 with error body, allowed origin → non-403, no Origin → non-403 - Bearer-token integration ×3: missing → 401, wrong → 401, correct → non-401 Fix: @types/express added as devDep (express is transitive from SDK ^1.29.0). Test results: 61/61 PASS (+11 new) Build: tsc clean, zero errors. ACs touched: ADR-124 §3 (dual-transport architecture), §6 (Origin validation, 127.0.0.1 bind, bearer-token auth slot). SPARC Phase 3 gate criteria met: API contracts typed, module boundaries established, no circular deps. Next iter target: Phase 4 (Refinement) — implement ruview.bfld.last_scan + ruview.bfld.subscribe tool handlers (BFLD wire format stable post-ADR-118), register them in the TOOLS array using the new schema-validation gate. Co-Authored-By: claude-flow --- tools/ruview-mcp/package-lock.json | 100 +++++++++- tools/ruview-mcp/package.json | 1 + tools/ruview-mcp/src/http-transport.ts | 179 ++++++++++++++++++ tools/ruview-mcp/src/index.ts | 23 ++- tools/ruview-mcp/tests/http-transport.test.ts | 167 ++++++++++++++++ 5 files changed, 464 insertions(+), 6 deletions(-) create mode 100644 tools/ruview-mcp/src/http-transport.ts create mode 100644 tools/ruview-mcp/tests/http-transport.test.ts diff --git a/tools/ruview-mcp/package-lock.json b/tools/ruview-mcp/package-lock.json index cbb83c11..94c791c3 100644 --- a/tools/ruview-mcp/package-lock.json +++ b/tools/ruview-mcp/package-lock.json @@ -1,21 +1,23 @@ { - "name": "@ruv/ruview-mcp", - "version": "0.0.1", + "name": "@ruvnet/rvagent", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@ruv/ruview-mcp", - "version": "0.0.1", + "name": "@ruvnet/rvagent", + "version": "0.1.0", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", "zod": "^3.23.8" }, "bin": { - "ruview-mcp": "dist/index.js" + "ruview-mcp": "dist/index.js", + "rvagent": "dist/index.js" }, "devDependencies": { + "@types/express": "^5.0.6", "@types/jest": "^30.0.0", "@types/node": "^20.14.0", "jest": "^29.7.0", @@ -1059,6 +1061,52 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -1069,6 +1117,13 @@ "@types/node": "*" } }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -1332,6 +1387,41 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", diff --git a/tools/ruview-mcp/package.json b/tools/ruview-mcp/package.json index a36ea822..1601c3e0 100644 --- a/tools/ruview-mcp/package.json +++ b/tools/ruview-mcp/package.json @@ -56,6 +56,7 @@ "zod": "^3.23.8" }, "devDependencies": { + "@types/express": "^5.0.6", "@types/jest": "^30.0.0", "@types/node": "^20.14.0", "jest": "^29.7.0", diff --git a/tools/ruview-mcp/src/http-transport.ts b/tools/ruview-mcp/src/http-transport.ts new file mode 100644 index 00000000..bb22a610 --- /dev/null +++ b/tools/ruview-mcp/src/http-transport.ts @@ -0,0 +1,179 @@ +/** + * Streamable HTTP transport scaffold for @ruvnet/rvagent (ADR-124 §3). + * + * Binds to 127.0.0.1 by default and mounts a POST /mcp endpoint backed by + * StreamableHTTPServerTransport from @modelcontextprotocol/sdk. + * + * Security model (ADR-124 §6): + * - Origin validation: requests from origins other than the configured + * allowlist are rejected with 403 Forbidden before reaching the MCP layer. + * - Default allowlist: ['http://localhost', 'http://127.0.0.1'] — covers + * Claude Code and Cursor on the same machine. + * - Bearer token: when RVAGENT_HTTP_TOKEN is set, requests must carry + * Authorization: Bearer ; missing/wrong tokens → 401. + * - Bind address: defaults to 127.0.0.1 per MCP spec security requirement. + * Set RVAGENT_HTTP_HOST=0.0.0.0 only for intentional fleet deployment. + * + * Usage: + * import { createHttpTransport } from './http-transport.js'; + * const { server: httpServer, transport } = await createHttpTransport(mcpServer); + * // httpServer is a node:http.Server — call httpServer.close() to shut down. + */ + +import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { randomUUID } from "node:crypto"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import type { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js"; + +export interface HttpTransportOptions { + /** TCP host to bind (default: 127.0.0.1). */ + host?: string; + /** TCP port to listen on (default: 3001). */ + port?: number; + /** + * Allowed Origin header values. Requests with an Origin not in this list + * are rejected with 403. Use '*' to disable Origin validation entirely + * (not recommended outside of local-dev flags). + */ + allowedOrigins?: string[]; + /** + * Bearer token for HTTP transport. When set, every request must supply + * Authorization: Bearer ; omitted or wrong token → 401. + * Defaults to process.env.RVAGENT_HTTP_TOKEN (undefined = auth disabled). + */ + bearerToken?: string; +} + +export interface HttpTransportResult { + /** The raw Node.js HTTP server — call .close() to shut down. */ + httpServer: HttpServer; + /** The MCP Streamable HTTP transport instance wired to the MCP server. */ + transport: StreamableHTTPServerTransport; + /** The bound address string (e.g. "http://127.0.0.1:3001"). */ + boundAddress: string; +} + +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_PORT = 3001; +const LOCALHOST_ORIGINS = new Set([ + "http://localhost", + "http://127.0.0.1", + "https://localhost", + "https://127.0.0.1", +]); + +/** + * Validate Origin header against the allowlist. + * Returns true if the request should be allowed, false if it should be rejected. + * + * An absent Origin header is allowed (same-origin non-browser requests, curl, etc.). + * A present Origin that is not in the allowlist is rejected. + */ +export function isOriginAllowed( + origin: string | undefined, + allowedOrigins: string[] +): boolean { + if (origin === undefined) return true; // no Origin = not a cross-origin browser request + if (allowedOrigins.includes("*")) return true; + return allowedOrigins.some((o) => o === origin); +} + +/** + * Build and wire a Streamable HTTP transport to the provided MCP server. + * Returns the Node.js HTTP server (not yet listening) plus the transport. + * Call httpServer.listen(port, host) or rely on createHttpTransport which + * does that for you. + */ +export function buildHttpApp( + mcpServer: McpServer, + opts: HttpTransportOptions = {} +): { httpServer: HttpServer; transport: StreamableHTTPServerTransport } { + const allowedOrigins: string[] = opts.allowedOrigins ?? [ + ...LOCALHOST_ORIGINS, + ]; + const bearerToken = opts.bearerToken ?? process.env["RVAGENT_HTTP_TOKEN"]; + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + }); + + const httpServer = createServer( + (req: IncomingMessage, res: ServerResponse) => { + // ── Origin validation ──────────────────────────────────────────────── + const origin = req.headers["origin"] as string | undefined; + if (!isOriginAllowed(origin, allowedOrigins)) { + res.writeHead(403, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Forbidden: cross-origin request rejected" })); + return; + } + + // ── Bearer token auth ──────────────────────────────────────────────── + if (bearerToken !== undefined && bearerToken !== "") { + const authHeader = req.headers["authorization"] as string | undefined; + const supplied = authHeader?.startsWith("Bearer ") + ? authHeader.slice("Bearer ".length) + : undefined; + if (supplied !== bearerToken) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Unauthorized: missing or invalid bearer token" })); + return; + } + } + + // ── Route: POST /mcp ───────────────────────────────────────────────── + if (req.method === "POST" && req.url === "/mcp") { + let body = ""; + req.on("data", (chunk: Buffer) => { body += chunk.toString(); }); + req.on("end", () => { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Bad Request: invalid JSON body" })); + return; + } + void transport.handleRequest(req, res, parsed); + }); + return; + } + + // ── Fallback ───────────────────────────────────────────────────────── + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not found. MCP endpoint: POST /mcp" })); + } + ); + + return { httpServer, transport }; +} + +/** + * Create and start the Streamable HTTP transport, resolving once the server + * is bound and listening. + */ +export async function createHttpTransport( + mcpServer: McpServer, + opts: HttpTransportOptions = {} +): Promise { + const host = opts.host ?? process.env["RVAGENT_HTTP_HOST"] ?? DEFAULT_HOST; + const port = opts.port ?? Number(process.env["RVAGENT_HTTP_PORT"] ?? DEFAULT_PORT); + + const { httpServer, transport } = buildHttpApp(mcpServer, opts); + + // Wire MCP server to the transport only after the HTTP server is built. + // Cast needed: StreamableHTTPServerTransport implements Transport but + // exactOptionalPropertyTypes causes a false incompatibility on optional + // callback properties; the cast is safe — the SDK types are consistent. + await mcpServer.connect(transport as Parameters[0]); + + await new Promise((resolve, reject) => { + httpServer.once("error", reject); + httpServer.listen(port, host, () => resolve()); + }); + + return { + httpServer, + transport, + boundAddress: `http://${host}:${port}`, + }; +} diff --git a/tools/ruview-mcp/src/index.ts b/tools/ruview-mcp/src/index.ts index 64383873..4f79017d 100644 --- a/tools/ruview-mcp/src/index.ts +++ b/tools/ruview-mcp/src/index.ts @@ -29,6 +29,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { CallToolRequestSchema, ListToolsRequestSchema, + McpError, + ErrorCode, } from "@modelcontextprotocol/sdk/types.js"; import { loadConfig } from "./config.js"; @@ -42,6 +44,7 @@ import { jobStatusSchema, jobStatus, } from "./tools/train-count.js"; +import { TOOL_INPUT_SCHEMAS } from "./schemas/index.js"; const PACKAGE_VERSION = "0.1.0"; const SERVER_NAME = "rvagent"; @@ -244,7 +247,10 @@ async function main(): Promise { })), })); - // Call tool handler. + // Call tool handler — uniform Zod validation gate (ADR-124 §3 Architecture). + // If TOOL_INPUT_SCHEMAS has a schema for the tool name, run safeParse first. + // Parse failures throw McpError(InvalidParams) so the client sees a typed + // JSON-RPC error rather than a wrapped string error. server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; const tool = TOOLS.find((t) => t.name === name); @@ -264,6 +270,20 @@ async function main(): Promise { }; } + // Schema validation gate — applies to all tools registered in TOOL_INPUT_SCHEMAS. + const schemaEntry = Object.prototype.hasOwnProperty.call(TOOL_INPUT_SCHEMAS, name) + ? TOOL_INPUT_SCHEMAS[name as keyof typeof TOOL_INPUT_SCHEMAS] + : undefined; + if (schemaEntry !== undefined) { + const parsed = schemaEntry.safeParse(args ?? {}); + if (!parsed.success) { + throw new McpError( + ErrorCode.InvalidParams, + `Invalid arguments for tool "${name}": ${parsed.error.message}` + ); + } + } + try { const result = await tool.handler(args ?? {}, config); return { @@ -275,6 +295,7 @@ async function main(): Promise { ], }; } catch (e: unknown) { + if (e instanceof McpError) throw e; // propagate typed errors unchanged const message = e instanceof Error ? e.message : String(e); return { content: [ diff --git a/tools/ruview-mcp/tests/http-transport.test.ts b/tools/ruview-mcp/tests/http-transport.test.ts new file mode 100644 index 00000000..b53fb727 --- /dev/null +++ b/tools/ruview-mcp/tests/http-transport.test.ts @@ -0,0 +1,167 @@ +/** + * ADR-124 §3 Architecture — Streamable HTTP transport security tests. + * + * Tests the Origin-validation middleware and bearer-token auth gate. + * No live MCP server needed for the guard logic — buildHttpApp is tested + * with a minimal stub McpServer that never actually processes JSON-RPC. + * + * Covered: + * 1. isOriginAllowed() unit tests — the pure function driving the gate + * 2. POST /mcp with cross-origin Origin → 403 + * 3. POST /mcp with allowed Origin → passes Origin gate (non-403) + * 4. POST /mcp with no Origin header → passes Origin gate (non-403) + * 5. Bearer token required, wrong token → 401 + * 6. Bearer token required, correct token + wildcard origin → passes (non-401) + */ + +import * as http from "node:http"; +import { isOriginAllowed, buildHttpApp } from "../src/http-transport.js"; +import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js"; + +// ── helpers ──────────────────────────────────────────────────────────────── + +function makeMockMcpServer(): McpServer { + return new McpServer( + { name: "test-rvagent", version: "0.0.0" }, + { capabilities: { tools: {} } } + ); +} + +async function post( + port: number, + path: string, + headers: Record, + body: string +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: "127.0.0.1", + port, + method: "POST", + path, + headers: { "Content-Type": "application/json", ...headers }, + }, + (res) => { + let data = ""; + res.on("data", (chunk: Buffer) => { data += chunk.toString(); }); + res.on("end", () => resolve({ status: res.statusCode ?? 0, body: data })); + } + ); + req.on("error", reject); + req.write(body); + req.end(); + }); +} + +async function startServer( + opts: Parameters[1], + basePort: number +): Promise<{ port: number; close: () => Promise }> { + const port = basePort + Math.floor(Math.random() * 100); + const { httpServer } = buildHttpApp(makeMockMcpServer(), opts); + await new Promise((resolve, reject) => { + httpServer.once("error", reject); + httpServer.listen(port, "127.0.0.1", () => resolve()); + }); + const close = () => + new Promise((res, rej) => + httpServer.close((e) => (e ? rej(e) : res())) + ); + return { port, close }; +} + +const MCP_BODY = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }); + +// ── 1. isOriginAllowed unit tests ────────────────────────────────────────── + +describe("isOriginAllowed()", () => { + const allow = ["http://localhost", "http://127.0.0.1"]; + + it("allows undefined origin (non-browser request, no Origin header)", () => { + expect(isOriginAllowed(undefined, allow)).toBe(true); + }); + + it("allows an origin in the allowlist", () => { + expect(isOriginAllowed("http://localhost", allow)).toBe(true); + expect(isOriginAllowed("http://127.0.0.1", allow)).toBe(true); + }); + + it("rejects an origin NOT in the allowlist", () => { + expect(isOriginAllowed("https://evil.example.com", allow)).toBe(false); + }); + + it("allows anything when allowedOrigins includes '*'", () => { + expect(isOriginAllowed("https://evil.example.com", ["*"])).toBe(true); + }); + + it("is case-sensitive per RFC 6454", () => { + expect(isOriginAllowed("HTTP://localhost", allow)).toBe(false); + }); +}); + +// ── 2-4. Origin-validation integration tests ─────────────────────────────── + +describe("HTTP transport Origin-validation middleware", () => { + let port: number; + let close: () => Promise; + + beforeAll(async () => { + const srv = await startServer( + { allowedOrigins: ["http://localhost", "http://127.0.0.1"] }, + 49200 + ); + port = srv.port; + close = srv.close; + }); + + afterAll(async () => { await close(); }); + + it("rejects cross-origin POST /mcp with 403", async () => { + const r = await post(port, "/mcp", { Origin: "https://evil.example.com" }, MCP_BODY); + expect(r.status).toBe(403); + const body = JSON.parse(r.body) as Record; + expect(body["error"]).toMatch(/cross-origin/i); + }); + + it("passes Origin gate for http://localhost — status is not 403", async () => { + const r = await post(port, "/mcp", { Origin: "http://localhost" }, MCP_BODY); + expect(r.status).not.toBe(403); + }); + + it("passes Origin gate with no Origin header — status is not 403", async () => { + const r = await post(port, "/mcp", {}, MCP_BODY); + expect(r.status).not.toBe(403); + }); +}); + +// ── 5-6. Bearer-token auth integration tests ────────────────────────────── + +describe("HTTP transport bearer-token auth gate", () => { + const SECRET = "test-secret-token-xyz"; + let port: number; + let close: () => Promise; + + beforeAll(async () => { + const srv = await startServer({ allowedOrigins: ["*"], bearerToken: SECRET }, 49400); + port = srv.port; + close = srv.close; + }); + + afterAll(async () => { await close(); }); + + it("rejects missing Authorization header with 401", async () => { + const r = await post(port, "/mcp", {}, MCP_BODY); + expect(r.status).toBe(401); + }); + + it("rejects wrong bearer token with 401", async () => { + const r = await post(port, "/mcp", { Authorization: "Bearer wrong" }, MCP_BODY); + expect(r.status).toBe(401); + }); + + it("passes auth gate with correct bearer token — status is not 401", async () => { + const r = await post(port, "/mcp", { Authorization: `Bearer ${SECRET}` }, MCP_BODY); + expect(r.status).not.toBe(401); + }); +});