diff --git a/SECURITY.md b/SECURITY.md index 1642953..8de4e5b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,6 +19,7 @@ If private vulnerability reporting is not available, open a minimal issue at =18.18'} - '@modelcontextprotocol/client@2.0.0': - resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==} + '@modelcontextprotocol/client@2.3.1': + resolution: {integrity: sha512-mIGZXpHsjnZ6lD+gD/WCMpR5k8yVQQ8nNFH1N0Srf7AvnwTUMYD6pvTY8ng2+He5FfV7YMZLmrjL7cLa/cc3dQ==} engines: {node: '>=20'} - '@modelcontextprotocol/core@2.0.0': - resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + '@modelcontextprotocol/core@2.3.1': + resolution: {integrity: sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==} engines: {node: '>=20'} - '@modelcontextprotocol/node@2.0.0': - resolution: {integrity: sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==} + '@modelcontextprotocol/node@2.1.1': + resolution: {integrity: sha512-EY92OAXN2xYL7tNS/MVsWJW1v8VHAPwl9B4Ca3pCrjmvqyC+S1C/6UHsMxF2GoFPraS5Yyg56vFz9brVpFojMA==} engines: {node: '>=20'} peerDependencies: - '@modelcontextprotocol/server': ^2.0.0 - hono: 4.13.7 - peerDependenciesMeta: - hono: - optional: true + '@modelcontextprotocol/server': ^2.3.0 - '@modelcontextprotocol/server@2.0.0': - resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} + '@modelcontextprotocol/server@2.3.1': + resolution: {integrity: sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==} engines: {node: '>=20'} '@smithy/core@3.33.3': @@ -907,8 +903,8 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concurrently@9.2.4: - resolution: {integrity: sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==} + concurrently@9.2.5: + resolution: {integrity: sha512-YMZlvWrTPQEhjKp60XSmMGt60+pffbv6DyRnOMvVO98+J7aF8w8Ui9FhdcOwvSixc/kyR6SoVvmejcn/2lZBqg==} engines: {node: '>=18'} hasBin: true @@ -1199,8 +1195,8 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jose@6.2.10: - resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} js-yaml@4.3.2: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} @@ -1326,8 +1322,8 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} punycode@2.3.1: @@ -1388,8 +1384,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.9.0: - resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} + shell-quote@1.12.0: + resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==} engines: {node: '>= 0.4'} side-channel-list@1.0.1: @@ -1527,6 +1523,9 @@ packages: zod@4.5.4: resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + snapshots: '@aws-crypto/sha256-js@5.2.0': @@ -1889,31 +1888,30 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@modelcontextprotocol/client@2.0.0': + '@modelcontextprotocol/client@2.3.1': dependencies: - '@modelcontextprotocol/core': 2.0.0 + '@modelcontextprotocol/core': 2.3.1 cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.1 - jose: 6.2.10 + jose: 6.2.12 pkce-challenge: 5.0.1 - zod: 4.5.4 + zod: 4.6.7 - '@modelcontextprotocol/core@2.0.0': + '@modelcontextprotocol/core@2.3.1': dependencies: - zod: 4.5.4 + zod: 4.6.7 - '@modelcontextprotocol/node@2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.7)': + '@modelcontextprotocol/node@2.1.1(@modelcontextprotocol/server@2.3.1)': dependencies: '@hono/node-server': 1.19.17(hono@4.13.7) - '@modelcontextprotocol/server': 2.0.0 - optionalDependencies: + '@modelcontextprotocol/server': 2.3.1 hono: 4.13.7 - '@modelcontextprotocol/server@2.0.0': + '@modelcontextprotocol/server@2.3.1': dependencies: - '@modelcontextprotocol/core': 2.0.0 - zod: 4.5.4 + '@modelcontextprotocol/core': 2.3.1 + zod: 4.6.7 '@smithy/core@3.33.3': dependencies: @@ -2191,11 +2189,11 @@ snapshots: concat-map@0.0.1: {} - concurrently@9.2.4: + concurrently@9.2.5: dependencies: chalk: 4.1.2 rxjs: 7.8.2 - shell-quote: 1.9.0 + shell-quote: 1.12.0 supports-color: 8.1.1 tree-kill: 1.2.2 yargs: 17.7.2 @@ -2378,7 +2376,7 @@ snapshots: on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 - proxy-addr: 2.0.7 + proxy-addr: 2.0.8 qs: 6.16.0 range-parser: 1.3.0 router: 2.2.0(supports-color@8.1.1) @@ -2513,7 +2511,7 @@ snapshots: isexe@2.0.0: {} - jose@6.2.10: {} + jose@6.2.12: {} js-yaml@4.3.2: dependencies: @@ -2619,7 +2617,7 @@ snapshots: prelude-ls@1.2.1: {} - proxy-addr@2.0.7: + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 @@ -2695,7 +2693,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.9.0: {} + shell-quote@1.12.0: {} side-channel-list@1.0.1: dependencies: @@ -2832,3 +2830,5 @@ snapshots: yocto-queue@0.1.0: {} zod@4.5.4: {} + + zod@4.6.7: {} diff --git a/services/flo-mcp/package.json b/services/flo-mcp/package.json index 6def006..f2ac0d3 100644 --- a/services/flo-mcp/package.json +++ b/services/flo-mcp/package.json @@ -26,9 +26,9 @@ "@flo/agent": "workspace:*", "@flo/domain": "workspace:*", "@flo/shared-types": "workspace:*", - "@modelcontextprotocol/client": "^2.0.0", - "@modelcontextprotocol/node": "^2.0.0", - "@modelcontextprotocol/server": "^2.0.0", + "@modelcontextprotocol/client": "^2.3.1", + "@modelcontextprotocol/node": "^2.1.1", + "@modelcontextprotocol/server": "^2.3.1", "zod": "^4.5.4" }, "devDependencies": { diff --git a/services/flo-mcp/src/customer-http.ts b/services/flo-mcp/src/customer-http.ts index 1d3b1d0..50485d8 100644 --- a/services/flo-mcp/src/customer-http.ts +++ b/services/flo-mcp/src/customer-http.ts @@ -5,6 +5,7 @@ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/cli import { CustomerAuthError, type CustomerWebsiteIdentity } from "@flo/agent"; import { z } from "zod"; import { createCustomerMcpServer, type CustomerRepairService } from "./customer-tools.js"; +import { MCP_MAX_REQUEST_BYTES } from "./mcp-limits.js"; const sessionCookie = "__Host-flo-session"; const stateCookie = "__Host-flo-lwa-state"; @@ -70,7 +71,7 @@ export const createCustomerHttp = (options: CustomerHttpOptions) => { const current = await auth.principal(bearer); if (current.subject !== principal.subject || current.customerId !== principal.customerId) throw new CustomerAuthError(401, "SIGN_IN_REQUIRED"); }; - const handler = createMcpHandler(() => createCustomerMcpServer(options.experience, principal, revalidate), { legacy: "stateless" }); + const handler = createMcpHandler(() => createCustomerMcpServer(options.experience, principal, revalidate), { legacy: "stateless", maxRequestBodySize: MCP_MAX_REQUEST_BYTES }); try { const response = await handler.fetch(request); return new Response(await response.arrayBuffer(), { status: response.status, headers: response.headers }); diff --git a/services/flo-mcp/src/customer-tools.ts b/services/flo-mcp/src/customer-tools.ts index 44bcce5..f5626f6 100644 --- a/services/flo-mcp/src/customer-tools.ts +++ b/services/flo-mcp/src/customer-tools.ts @@ -2,10 +2,12 @@ import { McpServer } from "@modelcontextprotocol/server"; import { z } from "zod"; import { customerRepairSchema, customerEstimateSchema, type CustomerExperience, type CustomerPrincipal } from "@flo/agent"; import { FloError } from "@flo/shared-types"; +import { MCP_MAX_TOOL_INPUT_ELEMENTS } from "./mcp-limits.js"; export type CustomerRepairService = Pick; export const createCustomerMcpServer = (experience: CustomerRepairService, principal: CustomerPrincipal, revalidate?: () => Promise): McpServer => { const server = new McpServer({ name: "flo-customer", version: "0.2.0" }, { + maxToolInputElements: MCP_MAX_TOOL_INPUT_ELEMENTS, instructions: "Vehicle-owner repair status and estimate review. Read only. Never claim approval, payment, booking, or cancellation occurred. Never request contact details or a VIN by voice. Ask which repair when more than one matches." }); const register = (name: string, description: string, inputSchema: z.ZodObject, output: O, operation: (input: z.infer>) => Promise>, summarize: (data: z.infer) => string) => { diff --git a/services/flo-mcp/src/mcp-limits.ts b/services/flo-mcp/src/mcp-limits.ts new file mode 100644 index 0000000..e78f46c --- /dev/null +++ b/services/flo-mcp/src/mcp-limits.ts @@ -0,0 +1,4 @@ +// Flo tool arguments are small identifiers and bounded operational text. +// Apply the same byte bound before Node buffering and SDK JSON parsing. +export const MCP_MAX_REQUEST_BYTES = 64 * 1024; +export const MCP_MAX_TOOL_INPUT_ELEMENTS = 1000; diff --git a/services/flo-mcp/src/server.ts b/services/flo-mcp/src/server.ts index 8c9bff8..89541d3 100644 --- a/services/flo-mcp/src/server.ts +++ b/services/flo-mcp/src/server.ts @@ -8,6 +8,7 @@ import type { Actor, Role } from "@flo/shared-types"; import { isDemoModeEnabled } from "./runtime-config.js"; import { createFloMcpServer } from "./tools.js"; import { createCustomerMcpServer } from "./customer-tools.js"; +import { MCP_MAX_REQUEST_BYTES } from "./mcp-limits.js"; export { isDemoModeEnabled } from "./runtime-config.js"; export { createCustomerWebsite } from "./customer-website.js"; @@ -50,14 +51,14 @@ export const createFloHttpServer = (options: FloServerOptions = {}): { server: H const adapters = options.adapters ?? defaultAdapters(); const orchestrator = new FloOrchestrator(adapters, new InMemoryJobMemoryStore(), options.clock); const demoMode = options.demoMode ?? isDemoModeEnabled(); - const handler = createMcpHandler(({ requestInfo }) => createFloMcpServer(orchestrator, actorFromRequest(requestInfo), { demoMode }), { legacy: "stateless" }); - const nodeHandler = toNodeHandler(handler); + const handler = createMcpHandler(({ requestInfo }) => createFloMcpServer(orchestrator, actorFromRequest(requestInfo), { demoMode }), { legacy: "stateless", maxRequestBodySize: MCP_MAX_REQUEST_BYTES }); + const nodeHandler = toNodeHandler(handler, { maxRequestBodySize: MCP_MAX_REQUEST_BYTES }); const customerExperience = new CustomerExperience(adapters.shop); // Synthetic identity is fixed server-side. Production stays closed until verified // OAuth account linking is implemented, including distinct service/user tokens. const customerHandler = createMcpHandler(() => createCustomerMcpServer(customerExperience, - { subject: "demo-owner", customerId: "customer-001" }), { legacy: "stateless" }); - const customerNodeHandler = toNodeHandler(customerHandler); + { subject: "demo-owner", customerId: "customer-001" }), { legacy: "stateless", maxRequestBodySize: MCP_MAX_REQUEST_BYTES }); + const customerNodeHandler = toNodeHandler(customerHandler, { maxRequestBodySize: MCP_MAX_REQUEST_BYTES }); const validateHost = options.allowedHostnames === undefined ? localhostHostValidation() : hostHeaderValidation(options.allowedHostnames); const validateOrigin = options.allowedOriginHostnames === undefined ? localhostOriginValidation() : originValidation(options.allowedOriginHostnames); diff --git a/services/flo-mcp/src/tools.ts b/services/flo-mcp/src/tools.ts index f90c55b..41a5f42 100644 --- a/services/flo-mcp/src/tools.ts +++ b/services/flo-mcp/src/tools.ts @@ -5,6 +5,7 @@ import type { Actor } from "@flo/shared-types"; import { FloError } from "@flo/shared-types"; import { toolOutputSchemas } from "./output-schemas.js"; import { isDemoModeEnabled } from "./runtime-config.js"; +import { MCP_MAX_TOOL_INPUT_ELEMENTS } from "./mcp-limits.js"; const readOnly: ToolAnnotations = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }; const internalMutation: ToolAnnotations = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }; @@ -48,6 +49,7 @@ export const createFloMcpServer = (orchestrator: FloOrchestrator, actor: Actor, const server = new McpServer( { name: "flo", version: "0.1.0" }, { + maxToolInputElements: MCP_MAX_TOOL_INPUT_ELEMENTS, instructions: "Flo is a hands-free operations layer. Use tools for all work-order, part, estimate, approval, purchase, and scheduling state. Never assert a transaction succeeded unless confirm_transaction returns success." } ); diff --git a/tests/integration/customer-signin.test.ts b/tests/integration/customer-signin.test.ts index 3ef64f2..52a0001 100644 --- a/tests/integration/customer-signin.test.ts +++ b/tests/integration/customer-signin.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import { after, before, describe, it } from "node:test"; import type { Server } from "node:http"; -import { CustomerWebsiteAuth, CustomerAuthError } from "@flo/agent"; +import { CustomerWebsiteAuth, CustomerAuthError, CustomerExperience } from "@flo/agent"; +import type * as CustomerHttpModule from "../../services/flo-mcp/dist/customer-http.js"; import { createCustomerWebsite } from "@flo/mcp"; import { createShopApi } from "@flo/mock-shop-api"; import { createHttpAdapters } from "@flo/adapters"; @@ -55,6 +56,26 @@ describe("customer website sign-in and MCP authorization (simulated Amazon provi assert.doesNotMatch(JSON.stringify(data), /"(?:customerId|vin|margin|diagnosis|accessToken)":/i); assert.equal((await post("/api/customer/command", { command: "Show my repairs", customerId: "customer-002" }, cookie)).status, 400); }); + + it("bounds the shared customer MCP handler before parsing and schema validation", async () => { + const { createCustomerHttp } = await import(new URL("../../../services/flo-mcp/dist/customer-http.js", import.meta.url).href) as typeof CustomerHttpModule; + const handler = createCustomerHttp({ auth, experience: new CustomerExperience(adapters.shop) }); + const cookie = await login("owner-one"); + const headers = { Origin: config.publicOrigin, Authorization: `Bearer ${cookie.split("=")[1]}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "mcp-protocol-version": "2025-11-25" }; + const post = (body: unknown) => handler(new Request(`${config.publicOrigin}/website/mcp`, { method: "POST", headers, body: JSON.stringify(body) }), "192.0.2.1"); + assert.equal((await post({ jsonrpc: "2.0", id: 1, method: "ping", params: { padding: "x".repeat(64 * 1024) } })).status, 413); + const batch = (count: number) => Array.from({ length: count }, () => ({ jsonrpc: "2.0", method: "notifications/initialized" })); + assert.equal((await post(batch(100))).status, 202); + assert.equal((await post(batch(101))).status, 400); + const client = new Client({ name: "customer-input-bound", version: "1" }, { versionNegotiation: { mode: "legacy" } }); + try { + await client.connect(new StreamableHTTPClientTransport(new URL(`${config.publicOrigin}/website/mcp`), { requestInit: { headers }, fetch: (input, init) => handler(new Request(input, init), "192.0.2.1") })); + const excessive = await client.callTool({ name: "list_my_repairs", arguments: { values: Array(1000).fill(0) } }); + assert.equal(excessive.isError, true); + assert.match(JSON.stringify(excessive.content), /1000|elements/i); + assert.notEqual((await client.callTool({ name: "list_my_repairs", arguments: {} })).isError, true); + } finally { await client.close(); } + }); it("isolates two signed-in customers, including internal job IDs, and refuses identity header overrides", async () => { const cookies = await Promise.all([login("owner-one"), login("owner-two")]); const work = await adapters.shop.listWorkOrders(); const first = work.find(item => item.workOrderNumber === "1842")!; diff --git a/tests/integration/mcp-transport.test.ts b/tests/integration/mcp-transport.test.ts index cca675f..fad77fd 100644 --- a/tests/integration/mcp-transport.test.ts +++ b/tests/integration/mcp-transport.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { request as httpRequest, type Server } from "node:http"; +import { createServer, request as httpRequest, type Server } from "node:http"; import { after, before, describe, it } from "node:test"; import express, { type Express } from "express"; import type * as CustomerRouterModule from "../../apps/alexa-simulator/dist/customer-router.js"; @@ -162,6 +162,68 @@ describe("Flo MCP Streamable HTTP transport", () => { } }); + it("bounds declared and streamed bodies, batches and tool arguments on both MCP surfaces", async () => { + const headers = { "Content-Type": "application/json", Accept: "application/json, text/event-stream", "mcp-protocol-version": "2025-11-25" }; + const batch = (count: number) => Array.from({ length: count }, () => ({ jsonrpc: "2.0", method: "notifications/initialized" })); + for (const path of ["/mcp", "/customer/mcp"]) { + const post = (body: unknown) => fetch(`${mcpUrl}${path}`, { method: "POST", headers, body: JSON.stringify(body) }); + assert.equal((await post({ jsonrpc: "2.0", id: 1, method: "ping", params: { padding: "x".repeat(64 * 1024) } })).status, 413); + const streamed = Buffer.from(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping", params: { padding: "é".repeat(33 * 1024) } })); + const status = await new Promise((resolve, reject) => { + const request = httpRequest(new URL(path, mcpUrl), { method: "POST", headers: { ...headers, "Transfer-Encoding": "chunked" } }, response => { response.resume(); response.on("end", () => resolve(response.statusCode!)); }); + request.on("error", reject); request.write(streamed.subarray(0, 32000)); request.end(streamed.subarray(32000)); + }); + assert.equal(status, 413); + assert.equal((await post(batch(100))).status, 202); + const excessiveBatch = await post(batch(101)); + assert.equal(excessiveBatch.status, 400); + assert.match(await excessiveBatch.text(), /100 messages/); + const client = new Client({ name: "bounded-input", version: "1" }, { versionNegotiation: { mode: "legacy" } }); + try { + await client.connect(new StreamableHTTPClientTransport(new URL(`${mcpUrl}${path}`))); + const name = path === "/mcp" ? "search_suppliers" : "list_my_repairs"; + const excessive = await client.callTool({ name, arguments: { partIds: Array(1000).fill("part-alternator-balanced") } }); + assert.equal(excessive.isError, true); + assert.match(JSON.stringify(excessive.content), /1000|elements/i); + const control = await client.callTool({ name, arguments: path === "/mcp" ? { partIds: Array(999).fill("part-alternator-balanced") } : {} }); + assert.notEqual(control.isError, true); + } finally { await client.close(); } + assert.equal((await fetch(`${mcpUrl}/health`)).status, 200); + } + }); + + it("keeps identity headers and bodies on their origin across 307 and 308 redirects", async () => { + let leakedRequests = 0; let sameOriginRequests = 0; + const sink = createServer((request, response) => { leakedRequests++; request.resume(); response.writeHead(204).end(); }); + const sinkUrl = await listenNode(sink); + const origin = createServer((request, response) => { void (async () => { + if (request.method !== "POST") { response.writeHead(405).end(); return; } + const chunks: Buffer[] = []; for await (const chunk of request) chunks.push(chunk as Buffer); + const body = Buffer.concat(chunks).toString(); + if (request.url?.startsWith("/cross")) { response.writeHead(Number(request.url.slice(-3)), { Location: `${sinkUrl}/capture` }).end(); return; } + if (request.url?.startsWith("/same")) { response.writeHead(Number(request.url.slice(-3)), { Location: "/destination" }).end(); return; } + sameOriginRequests++; + assert.equal(request.headers["x-flo-role"], "technician"); + assert.equal(request.headers["x-flo-actor-id"], "synthetic-redirect-test"); + const message = JSON.parse(body) as { id?: number; method: string }; + if (message.id === undefined) { response.writeHead(202).end(); return; } + assert.equal(message.method, "initialize"); + response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: "2025-11-25", capabilities: {}, serverInfo: { name: "redirect-control", version: "1" } } })); + })().catch((error: unknown) => response.destroy(error instanceof Error ? error : new Error("Redirect fixture failed"))); }); + const originUrl = await listenNode(origin); + try { + for (const path of ["/cross307", "/cross308", "/same307", "/same308", "/destination"]) { + const client = new Client({ name: "redirect-test", version: "1" }, { versionNegotiation: { mode: "legacy" } }); + try { + const connect = client.connect(new StreamableHTTPClientTransport(new URL(`${originUrl}${path}`), { requestInit: { headers: { "x-flo-role": "technician", "x-flo-actor-id": "synthetic-redirect-test" } } })); + if (path.startsWith("/cross")) await assert.rejects(connect, /redirect|origin/i); else await connect; + } finally { await client.close(); } + } + assert.equal(leakedRequests, 0); + assert.ok(sameOriginRequests >= 3); + } finally { await close(origin); await close(sink); } + }); + it("isolates the owner tool surface and ignores client-supplied identity headers", async () => { const client = new Client({ name: "flo-owner-test", version: "0.2.0" }, { versionNegotiation: { mode: "legacy" } }); try { diff --git a/tests/package.json b/tests/package.json index 1c133ae..962c613 100644 --- a/tests/package.json +++ b/tests/package.json @@ -8,7 +8,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/client": "^2.3.1", "@flo/adapters": "workspace:*", "@flo/agent": "workspace:*", "@flo/domain": "workspace:*",