From fbd1fbaf4422dc34f9902d678ba33dff9a4677e1 Mon Sep 17 00:00:00 2001 From: ammann <161159040+agammann@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:39:18 -0700 Subject: [PATCH 1/3] Update vulnerable development and transport dependencies --- apps/alexa-simulator/package.json | 2 +- package.json | 4 +- pnpm-lock.yaml | 73 ++++++++++++++++++------------- services/flo-mcp/package.json | 2 +- tests/package.json | 2 +- 5 files changed, 48 insertions(+), 35 deletions(-) diff --git a/apps/alexa-simulator/package.json b/apps/alexa-simulator/package.json index 315ae5e..ebc93d3 100644 --- a/apps/alexa-simulator/package.json +++ b/apps/alexa-simulator/package.json @@ -12,7 +12,7 @@ "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/credential-provider-node": "^3.972.82", - "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/client": "^2.2.0", "@smithy/protocol-http": "^5.6.2", "@smithy/signature-v4": "^5.7.3", "express": "^5.2.1" diff --git a/package.json b/package.json index a7ed41d..f5a12e0 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,9 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", - "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/client": "^2.2.0", "@types/node": "^24.10.0", - "concurrently": "^9.2.1", + "concurrently": "^9.2.5", "esbuild": "^0.28.2", "eslint": "^9.39.1", "tsx": "^4.23.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index faa12fc..4d78abe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,14 +15,14 @@ importers: specifier: ^9.39.1 version: 9.39.5 '@modelcontextprotocol/client': - specifier: ^2.0.0 - version: 2.0.0 + specifier: ^2.2.0 + version: 2.2.0 '@types/node': specifier: ^24.10.0 version: 24.13.3 concurrently: - specifier: ^9.2.1 - version: 9.2.4 + specifier: ^9.2.5 + version: 9.2.5 esbuild: specifier: ^0.28.2 version: 0.28.2 @@ -48,8 +48,8 @@ importers: specifier: ^3.972.82 version: 3.972.82 '@modelcontextprotocol/client': - specifier: ^2.0.0 - version: 2.0.0 + specifier: ^2.2.0 + version: 2.2.0 '@smithy/protocol-http': specifier: ^5.6.2 version: 5.6.2 @@ -180,8 +180,8 @@ importers: specifier: workspace:* version: link:../../packages/shared-types '@modelcontextprotocol/client': - specifier: ^2.0.0 - version: 2.0.0 + specifier: ^2.2.0 + version: 2.2.0 '@modelcontextprotocol/node': specifier: ^2.0.0 version: 2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.7) @@ -329,8 +329,8 @@ importers: specifier: workspace:* version: link:../packages/shared-types '@modelcontextprotocol/client': - specifier: ^2.0.0 - version: 2.0.0 + specifier: ^2.2.0 + version: 2.2.0 express: specifier: ^5.2.1 version: 5.2.1(supports-color@8.1.1) @@ -667,14 +667,18 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@modelcontextprotocol/client@2.0.0': - resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==} + '@modelcontextprotocol/client@2.2.0': + resolution: {integrity: sha512-LxCou/CSYQ6dwEnjhLZY0KnEuc8V4UJ3IEQCl/uR2yHobSQIEdJKUlKLRYRF5i5FqRGHy1eK7une3aAWDHLtig==} engines: {node: '>=20'} '@modelcontextprotocol/core@2.0.0': resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} engines: {node: '>=20'} + '@modelcontextprotocol/core@2.2.0': + resolution: {integrity: sha512-iLhmprRmWI8EcosOA3wVvww22z02NkgqhV4fBH6f/odQBsi7xnJc0HGmi21yWO+/iKw2yzqVE127Zhna5/+JXw==} + engines: {node: '>=20'} + '@modelcontextprotocol/node@2.0.0': resolution: {integrity: sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==} engines: {node: '>=20'} @@ -907,8 +911,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 +1203,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 +1330,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 +1392,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 +1531,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,20 +1896,24 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@modelcontextprotocol/client@2.0.0': + '@modelcontextprotocol/client@2.2.0': dependencies: - '@modelcontextprotocol/core': 2.0.0 + '@modelcontextprotocol/core': 2.2.0 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': dependencies: zod: 4.5.4 + '@modelcontextprotocol/core@2.2.0': + dependencies: + zod: 4.6.7 + '@modelcontextprotocol/node@2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.7)': dependencies: '@hono/node-server': 1.19.17(hono@4.13.7) @@ -2191,11 +2202,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 +2389,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 +2524,7 @@ snapshots: isexe@2.0.0: {} - jose@6.2.10: {} + jose@6.2.12: {} js-yaml@4.3.2: dependencies: @@ -2619,7 +2630,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 +2706,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 +2843,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..a1227f9 100644 --- a/services/flo-mcp/package.json +++ b/services/flo-mcp/package.json @@ -26,7 +26,7 @@ "@flo/agent": "workspace:*", "@flo/domain": "workspace:*", "@flo/shared-types": "workspace:*", - "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/client": "^2.2.0", "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "zod": "^4.5.4" diff --git a/tests/package.json b/tests/package.json index 1c133ae..00ecf80 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.2.0", "@flo/adapters": "workspace:*", "@flo/agent": "workspace:*", "@flo/domain": "workspace:*", From a82c9d4e9a12dee9210e21ff175e5818579f75a0 Mon Sep 17 00:00:00 2001 From: ammann <161159040+agammann@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:53:54 -0700 Subject: [PATCH 2/3] Renew reviewed narrator exceptions through November 18 Owner approved renewal on October 18, 2026 for the same synchronous no-DLQ and no-customer-VPC source exceptions. Required controls, reasons, and infrastructure resources remain unchanged. --- infra/aws/bedrock-narrator/README.md | 2 +- infra/aws/bedrock-narrator/policy-exceptions.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/infra/aws/bedrock-narrator/README.md b/infra/aws/bedrock-narrator/README.md index 8401f0e..55a6c94 100644 --- a/infra/aws/bedrock-narrator/README.md +++ b/infra/aws/bedrock-narrator/README.md @@ -82,7 +82,7 @@ The September 5 source revision addresses six baseline policy findings and recor - **Allowance encryption:** the retained DynamoDB table explicitly uses a separate rotating customer-managed key. The narrator key policy grants only `kms:Decrypt`, constrained by the account, regional DynamoDB service and exact table encryption context. The deployment identity needs the appropriate key-management permissions; application runtime permissions are not deployment permissions. - **Recovery:** DynamoDB point-in-time recovery uses a seven-day window. [The recovery runbook](recovery.md) prohibits reconnecting an old allowance snapshot without reconciliation; unknown consumption fails closed with zero remaining allowance. Backups are not authority to grant new model calls. - **IAM governance:** the existing narrow Bedrock/DynamoDB statements move from inline policy into an attached customer-managed policy. The fixed DynamoDB partition-key condition also rejects a missing leading-key context. The existing AWS basic logging policy remains; this change does not claim that IAM makes the allowance immutable. -- **Scoped exceptions:** no dead-letter queue for this synchronous API workflow, and no customer VPC while there are no private-network dependencies. Re-review by October 5, 2026, or before either assumption changes. IAM ingress and TLS remain required. These are accepted findings, not claims that the raw Guard rules pass. +- **Scoped exceptions:** no dead-letter queue for this synchronous API workflow, and no customer VPC while there are no private-network dependencies. The owner re-reviewed and renewed these same two source exceptions on October 18, 2026. Re-review by November 18, 2026, or before either assumption changes. IAM ingress and TLS remain required. These are accepted findings, not claims that the raw Guard rules pass. This renewal records source approval only. Quota history: the initial 102 request and subsequent owner-approved 1,001 request are recorded in [deployment preparation](../../../docs/verification/aws-deployment-preparation-2026-09-05.md). At the final review, request `9aee223c1a5b44e9a2d68fd7b3108dae0Lq1YWJJ` still reported **CASE_OPENED**, while the applied quota independently read **1,000**. This does not establish approval of 1,001. No quota request was changed during this deployment. diff --git a/infra/aws/bedrock-narrator/policy-exceptions.json b/infra/aws/bedrock-narrator/policy-exceptions.json index 92863da..84560b1 100644 --- a/infra/aws/bedrock-narrator/policy-exceptions.json +++ b/infra/aws/bedrock-narrator/policy-exceptions.json @@ -1,6 +1,6 @@ { - "approvedOn": "2026-09-05", - "reviewBy": "2026-10-05", + "approvedOn": "2026-10-18", + "reviewBy": "2026-11-18", "resource": "NarratorFunction", "exceptions": [ { From 4b728f4642c399b29362fdf7e1848f8e5fd9b1c9 Mon Sep 17 00:00:00 2001 From: ammann <161159040+agammann@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:04:38 -0700 Subject: [PATCH 3/3] Bound MCP requests and keep client redirects on their origin --- SECURITY.md | 1 + apps/alexa-simulator/package.json | 2 +- package.json | 2 +- pnpm-lock.yaml | 71 +++++++++-------------- services/flo-mcp/package.json | 6 +- services/flo-mcp/src/customer-http.ts | 3 +- services/flo-mcp/src/customer-tools.ts | 2 + services/flo-mcp/src/mcp-limits.ts | 4 ++ services/flo-mcp/src/server.ts | 9 +-- services/flo-mcp/src/tools.ts | 2 + tests/integration/customer-signin.test.ts | 23 +++++++- tests/integration/mcp-transport.test.ts | 64 +++++++++++++++++++- tests/package.json | 2 +- 13 files changed, 136 insertions(+), 55 deletions(-) create mode 100644 services/flo-mcp/src/mcp-limits.ts 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.2.0': - resolution: {integrity: sha512-LxCou/CSYQ6dwEnjhLZY0KnEuc8V4UJ3IEQCl/uR2yHobSQIEdJKUlKLRYRF5i5FqRGHy1eK7une3aAWDHLtig==} + '@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/core@2.2.0': - resolution: {integrity: sha512-iLhmprRmWI8EcosOA3wVvww22z02NkgqhV4fBH6f/odQBsi7xnJc0HGmi21yWO+/iKw2yzqVE127Zhna5/+JXw==} - 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': @@ -1896,9 +1888,9 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@modelcontextprotocol/client@2.2.0': + '@modelcontextprotocol/client@2.3.1': dependencies: - '@modelcontextprotocol/core': 2.2.0 + '@modelcontextprotocol/core': 2.3.1 cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.1 @@ -1906,25 +1898,20 @@ snapshots: pkce-challenge: 5.0.1 zod: 4.6.7 - '@modelcontextprotocol/core@2.0.0': - dependencies: - zod: 4.5.4 - - '@modelcontextprotocol/core@2.2.0': + '@modelcontextprotocol/core@2.3.1': dependencies: 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: diff --git a/services/flo-mcp/package.json b/services/flo-mcp/package.json index a1227f9..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.2.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 00ecf80..962c613 100644 --- a/tests/package.json +++ b/tests/package.json @@ -8,7 +8,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@modelcontextprotocol/client": "^2.2.0", + "@modelcontextprotocol/client": "^2.3.1", "@flo/adapters": "workspace:*", "@flo/agent": "workspace:*", "@flo/domain": "workspace:*",