feat(adr-124/architecture): schema-validation gate + Streamable HTTP transport (ADR-124 §3)
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 <ruv@ruv.net>
This commit is contained in:
parent
7b7dbc7980
commit
092152bd73
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 <token>; 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 <token>; 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<HttpTransportResult> {
|
||||
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<typeof mcpServer.connect>[0]);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.once("error", reject);
|
||||
httpServer.listen(port, host, () => resolve());
|
||||
});
|
||||
|
||||
return {
|
||||
httpServer,
|
||||
transport,
|
||||
boundAddress: `http://${host}:${port}`,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<void> {
|
|||
})),
|
||||
}));
|
||||
|
||||
// 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<void> {
|
|||
};
|
||||
}
|
||||
|
||||
// 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<void> {
|
|||
],
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof McpError) throw e; // propagate typed errors unchanged
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
content: [
|
||||
|
|
|
|||
|
|
@ -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<string, string>,
|
||||
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<typeof buildHttpApp>[1],
|
||||
basePort: number
|
||||
): Promise<{ port: number; close: () => Promise<void> }> {
|
||||
const port = basePort + Math.floor(Math.random() * 100);
|
||||
const { httpServer } = buildHttpApp(makeMockMcpServer(), opts);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.once("error", reject);
|
||||
httpServer.listen(port, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const close = () =>
|
||||
new Promise<void>((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<void>;
|
||||
|
||||
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<string, unknown>;
|
||||
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<void>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue