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:
ruv 2026-05-24 22:08:37 -04:00
parent 7b7dbc7980
commit 092152bd73
5 changed files with 464 additions and 6 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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}`,
};
}

View File

@ -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: [

View File

@ -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);
});
});