diff --git a/CHANGELOG.md b/CHANGELOG.md index 070bb58b..537fbca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- **A tool call's arguments may hold at most 100,000 array elements and object keys, on every transport.** The MCP SDK can count them before it validates the arguments, but only when the server sets a limit, and Iris set none ([GHSA-46jr-4wx8-v453](https://github.com/advisories/GHSA-46jr-4wx8-v453), high). Arguments that fail validation are reported element by element, so one 1 MB request of 524,229 elements (zeros where `spans` belong) cost the server 260 MB, and 20 requests of that size at once, the MCP endpoint's per-minute limit for one client, up to 1,148 MB and 41 s to answer them. A call over the limit now gets an `isError` result that names the limit, before its arguments are validated, and the session goes on. The default clears every legitimate input measured, even one that fills the whole 1 MB request: real agent transcripts reach about 12,600 elements at 1 MB, and the densest shape found, a trace of 201 short tool calls, 68,506. Set it with `security.maxToolInputElements` in `config.json` or `IRIS_MAX_TOOL_INPUT_ELEMENTS`. A server reachable beyond loopback can go lower: at 10,000 the same burst costs about 250 MB. `scripts/bench-tool-input-elements.ts` measures it. +- **Iris requires `@modelcontextprotocol/sdk` 1.32.0 or later.** It required 1.30.0. A published package does not carry Iris's lockfile, so a project that already held SDK 1.30.x or 1.31.x kept it, and those versions are affected by [GHSA-46jr-4wx8-v453](https://github.com/advisories/GHSA-46jr-4wx8-v453), [GHSA-22jm-h49p-29qw](https://github.com/advisories/GHSA-22jm-h49p-29qw), [GHSA-rvq5-wwqv-78pq](https://github.com/advisories/GHSA-rvq5-wwqv-78pq) and [GHSA-6prh-2h8m-c8cw](https://github.com/advisories/GHSA-6prh-2h8m-c8cw), and 1.30.x also by [GHSA-6qxp-vccf-f47h](https://github.com/advisories/GHSA-6qxp-vccf-f47h) and, at 1.30.0, [GHSA-f897-62hf-6gxq](https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/GHSA-f897-62hf-6gxq). The element limit above also needs 1.32.0: an older SDK ignores the option. Iris's own lockfile already installed 1.32.0. +- **`ip-address` 10.7.4** ([GHSA-rxp7-6hxx-x8q5](https://github.com/advisories/GHSA-rxp7-6hxx-x8q5), moderate: `Address6` strips a zone identifier in quadratic time before applying its length limit). Iris reaches it only through `express-rate-limit`, which parses the request's own socket address: Iris leaves Express's `trust proxy` off, so a client cannot hand it a long string. The `overrides` floor is raised from 10.5.1 to 10.7.4. - **With an allowlist set, the citation fetcher resolves only the hosts on it.** `IRIS_CITATION_DOMAINS` is now checked before the DNS lookup, so a host that an output cites and the list does not hold is refused with `not_allowed_domain` and never looked up. It applies at every redirect hop. A host that is both outside the list and unsafe, such as a private address, now reports `not_allowed_domain` where it reported `ssrf`. - **BREAKING — A tool argument can no longer widen what the operator allows for fetching or spend.** Three arguments could. `verify_citations` with `allow_fetch: true` fetched the cited URLs on a server whose operator had not set `IRIS_CITATION_ALLOW_FETCH=1`; its `domain_allowlist` was added to the operator's `IRIS_CITATION_DOMAINS` instead of narrowing it; and `max_cost_usd` on `evaluate_with_llm_judge` and `max_cost_usd_total` on `verify_citations` replaced the operator's cap with any larger number. An agent's arguments can be steered by text it read, including text stored in Iris, so each is now a ceiling: an argument can narrow the operator's setting for one call and never widen it. When one asks for more, the operator's setting applies and the call goes on, the response carries a warning with code `IRIS_ARGUMENT_NARROWED` naming the argument and the setting that applied, and the stored evaluation keeps the same fact (`provenance.narrowed`) with a sentence to the operator in its `interpretations`, on every later read. Fetching now needs `IRIS_CITATION_ALLOW_FETCH=1` on the server; a caller that relied on `allow_fetch: true` gets no fetch and the warning. The per-call cap on `verify_citations` is a new setting, `IRIS_CITATION_MAX_COST_USD_TOTAL`, default 1 USD as before. - **BREAKING — Every judge call on your key draws on one daily budget.** `evaluate_with_llm_judge` and `verify_citations` had a per-call cap and no daily limit, so an agent calling either in a loop, or steered into doing so, could spend the key without end; only the relevance judge had a daily budget. All three now share one, `IRIS_LLM_JUDGE_DAILY_BUDGET_USD` (default 1 USD per UTC day, per tenant, kept in the database). A call is made only if its worst case fits in what is left. When it does not, nothing is spent: `evaluate_with_llm_judge` answers `IRIS_BUDGET_EXCEEDED` (retryable, with the time the budget resets), and `verify_citations` marks the citation `daily_budget_reached` and makes no further call. A deployment that spends more than 1 USD a day on the judge tools raises the budget. The variable was `IRIS_RELEVANCE_JUDGE_DAILY_BUDGET_USD`; that name is still read when the new one is unset, and the server says so at startup. In the Claude Desktop extension the setting is now "LLM judge daily budget", and a value set under the old one is not carried over. Today's spend is `judge.dailyBudget` on `iris://capabilities`. diff --git a/README.md b/README.md index 4977cca6..77e9cec0 100644 --- a/README.md +++ b/README.md @@ -480,6 +480,7 @@ Every variable `--help` documents. CLI flags take precedence over environment va | `IRIS_API_KEY_FILE` | Path to a file whose trimmed contents are the API key — the secret-file pattern Docker and Kubernetes mount, so the key never sits in an environment block. Set this or `IRIS_API_KEY`, not both | | `IRIS_ALLOW_UNAUTHENTICATED` | Set to `1` to run a non-loopback bind with **no** key on purpose (lifts the refusal; the network is then your boundary) | | `IRIS_ALLOWED_ORIGINS` | Comma-separated origin allowlist. Dashboard: CORS headers (supports globs, e.g. `http://localhost:*`). HTTP transport: exact-match `Origin` allowlist for DNS-rebinding protection (globs ignored; the server's own loopback origins are always allowed) | +| `IRIS_MAX_TOOL_INPUT_ELEMENTS` | The most array elements and object keys one tool call's arguments may hold, on every transport (a whole number, default `100000`). A call over it gets an error result before its arguments are validated. The default clears every legitimate input measured, even one that fills the whole 1 MB request; raise it if you raise `security.requestSizeLimit` for denser data. Also `security.maxToolInputElements` in `config.json` | | `IRIS_NO_AUTO_LAUNCH` | Set to `1` to disable the first-run dashboard auto-launch | | `IRIS_ANTHROPIC_API_KEY` | Required by `evaluate_with_llm_judge` + `verify_citations` with `provider=anthropic` | | `IRIS_OPENAI_API_KEY` | Required by `evaluate_with_llm_judge` + `verify_citations` with `provider=openai` | @@ -509,6 +510,7 @@ When using HTTP transport, Iris includes: - Zod input validation on all routes - ReDoS-safe regex for custom eval rules - One 1MB request size limit on every transport (`security.requestSizeLimit`): HTTP answers `413`, stdio answers a JSON-RPC error and keeps the session open +- One limit of 100,000 array elements and object keys in a tool call's arguments on every transport (`security.maxToolInputElements`), counted before the arguments are validated: a call over it gets an error result ```bash # Production deployment diff --git a/SECURITY-EXPOSURE.md b/SECURITY-EXPOSURE.md index a93e9c35..e7f4f7e3 100644 --- a/SECURITY-EXPOSURE.md +++ b/SECURITY-EXPOSURE.md @@ -283,6 +283,17 @@ All four are transitive-only (`brace-expansion` via dev-tooling globs, `fast-uri **Gate after:** `npm audit` reports 0 vulnerabilities in the root, `dashboard/` and `website/`. +### 2026-10-22 — `ip-address` patched by override and lockfile bump (Linux-regenerated); the MCP SDK floor raised to 1.32.0 and its tool-input element limit turned on + +| Advisory(ies) | Package | Was → Now | Load path | Decision | +|---|---|---|---|---| +| [GHSA-rxp7-6hxx-x8q5](https://github.com/advisories/GHSA-rxp7-6hxx-x8q5) (moderate — `Address6` strips a zone identifier in quadratic time before its length limit; 100,000 `%` characters and a newline take 3.9 s) | `ip-address` | 10.7.2 → 10.7.4 | root ← `express-rate-limit@8.7.0` — runtime: shipped in the npm package, the image and the MCPB bundle. **Not reachable from a client:** `express-rate-limit` constructs `Address6` only in `ipKeyGenerator`, from `req.ip`, after `net.isIPv6()` accepts it. iris leaves Express's `trust proxy` off (`grep -rn "trust proxy" src`: no hits), so `req.ip` is the socket's remote address as the operating system reports it, never a string a client sends. iris's source does not import `ip-address`. | **Patch** — `overrides.ip-address` raised from `^10.5.1` to `^10.7.4` so the floor excludes the advisory, and the lockfile moved with it | +| [GHSA-46jr-4wx8-v453](https://github.com/advisories/GHSA-46jr-4wx8-v453) (high — one HTTP request within the default size limit could exhaust server memory during validation), and for the floor [GHSA-22jm-h49p-29qw](https://github.com/advisories/GHSA-22jm-h49p-29qw), [GHSA-rvq5-wwqv-78pq](https://github.com/advisories/GHSA-rvq5-wwqv-78pq), [GHSA-6prh-2h8m-c8cw](https://github.com/advisories/GHSA-6prh-2h8m-c8cw), [GHSA-6qxp-vccf-f47h](https://github.com/advisories/GHSA-6qxp-vccf-f47h), [GHSA-f897-62hf-6gxq](https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/GHSA-f897-62hf-6gxq) | `@modelcontextprotocol/sdk` | range `^1.30.0` → `^1.32.0` (the lockfile already installed 1.32.0) | root, a direct runtime dependency. iris imports `server/mcp.js`, `server/stdio.js`, `server/streamableHttp.js` and `types.js` only, so of the six only GHSA-46jr-4wx8-v453 (tool-input validation in `McpServer`) and GHSA-f897-62hf-6gxq (HTTP body and batch size; iris's own `express.json` 1 MB limit runs before the SDK sees a body) touch code iris runs; the bearer-auth, tasks and client advisories are in modules iris never loads. | **Patch** — the floor, because a published package does not carry iris's lockfile and an install that already held 1.30.x or 1.31.x kept it; and an iris-side change, because GHSA-46jr-4wx8-v453's fix is an `McpServer` option that is off by default: `security.maxToolInputElements` (default 100,000, `IRIS_MAX_TOOL_INPUT_ELEMENTS`) is now set on every MCP server iris builds, stdio and HTTP. The measurements behind the number are in `src/config/defaults.ts`; tests: `tests/integration/tool-input-elements.test.ts` (five of its six fail without the option) | + +**Lockfile provenance.** Regenerated on Linux (`node:24`, npm 11.19.0) with `npm install --package-lock-only --ignore-scripts --no-audit --no-fund` after the two `package.json` edits. The root lockfile changed in two places: the root package's declared SDK range, and the version, `resolved` and `integrity` of `ip-address`. No entry added or removed (`git diff origin/main -- package-lock.json | grep -cE '^-\s+"node_modules/'` = 0), `libc` fields (10) and integrity hashes (392) unchanged in number, `@emnapi` entries unchanged (0, as before). + +**Gate after:** `npm audit` at the root reports only GHSA-86w9-cpqp-85rv (`node-forge`, recorded above). + ### Untrusted JSON Schema — the tool-argument path (v0.11.0+) diff --git a/docs/api-reference.md b/docs/api-reference.md index caf1bfa2..4d8446ac 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -901,7 +901,7 @@ Since 0.9.0 the server is agent-native in three ways, all locked by `tests/integ - **Instructions.** The `initialize` response carries server instructions built at boot from this server's runtime state: the rule count and bundles, the effective critical list after `eval.criticalRules` / `eval.nonCriticalRules`, the pass threshold, whether a judge key reached the process, and the resources. A client that shows instructions gets the frame before it lists a tool; a client that hides them gets the same facts from `iris://capabilities`. - **Structured responses.** Every tool declares an `outputSchema` (its top-level fields and their types; each response is validated against the full schema before it is sent, and what each field means is in `toolGuide` in `iris://capabilities`) and returns the same object as `content[0].text` (JSON) and as `structuredContent`. Responses that create something link it as a `resource_link` content item (`log_trace` → `iris://traces/{trace_id}`; the three verdict tools → `iris://evaluations/{id}` and the trace when linked; `list_rules` → `iris://proof`). -- **Structured errors.** A failure inside a tool returns `isError: true` with `{"error": {"code", "message", "recovery": [], "retryable", "field"?, "valid"?, "see"?, "kind"?, "retryAfterMs"?}}` as text and as `structuredContent`, plus a link to `iris://capabilities`. Codes: `IRIS_UNKNOWN_TRACE`, `IRIS_DUPLICATE_RULE`, `IRIS_INVALID_RULE_CONFIG`, `IRIS_JUDGE_NOT_ENABLED` (its `recovery` is the enable workflow), `IRIS_JUDGE_UNKNOWN_MODEL` (`valid` lists the models), `IRIS_BUDGET_EXCEEDED`, `IRIS_PROVIDER_ERROR` (`kind`: auth · rate_limit · bad_request · server_error · timeout · malformed_response), `IRIS_JUDGE_FAILED`, `IRIS_STORAGE_ERROR`, `IRIS_INTERNAL_ERROR`. An argument the input schema rejects never reaches the handler: the protocol layer answers with plain text that names the offending argument, the valid arguments and `IRIS_INVALID_ARGUMENT`. Every code is provoked over a real transport by `tests/unit/tools/error-codes.test.ts`, and the provoked set must equal the catalogue. +- **Structured errors.** A failure inside a tool returns `isError: true` with `{"error": {"code", "message", "recovery": [], "retryable", "field"?, "valid"?, "see"?, "kind"?, "retryAfterMs"?}}` as text and as `structuredContent`, plus a link to `iris://capabilities`. Codes: `IRIS_UNKNOWN_TRACE`, `IRIS_DUPLICATE_RULE`, `IRIS_INVALID_RULE_CONFIG`, `IRIS_JUDGE_NOT_ENABLED` (its `recovery` is the enable workflow), `IRIS_JUDGE_UNKNOWN_MODEL` (`valid` lists the models), `IRIS_BUDGET_EXCEEDED`, `IRIS_PROVIDER_ERROR` (`kind`: auth · rate_limit · bad_request · server_error · timeout · malformed_response), `IRIS_JUDGE_FAILED`, `IRIS_STORAGE_ERROR`, `IRIS_INTERNAL_ERROR`. An argument the input schema rejects never reaches the handler: the protocol layer answers with plain text that names the offending argument, the valid arguments and `IRIS_INVALID_ARGUMENT`. Arguments that hold more array elements and object keys than `security.maxToolInputElements` (`IRIS_MAX_TOOL_INPUT_ELEMENTS`, default 100,000) are refused the same way before the schema runs, on every transport, with text that says `arguments contain more than the maximum of 100000 elements`: send fewer tool calls or spans in one call, or raise the setting. Every code is provoked over a real transport by `tests/unit/tools/error-codes.test.ts`, and the provoked set must equal the catalogue. One prompt is registered, `evaluate-my-agent` (optional argument `what`: `output` or `trace-file`): a walk of log → evaluate → read → explain, rendered from the same facts as the instructions. Clients without prompt support never see it and nothing depends on it. diff --git a/package-lock.json b/package-lock.json index fe53f875..e0eb1cac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.20.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/sdk": "^1.32.0", "ajv": "^8.17.1", "express": "^5.1.0", "express-rate-limit": "^8.3.2", @@ -3453,9 +3453,9 @@ "optional": true }, "node_modules/ip-address": { - "version": "10.7.2", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", - "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", "license": "MIT", "engines": { "node": ">= 12" diff --git a/package.json b/package.json index 434cdcc7..47b9dccb 100644 --- a/package.json +++ b/package.json @@ -128,13 +128,13 @@ "overrides": { "brace-expansion": "^5.0.12", "fast-uri": "^3.1.8", - "ip-address": "^10.5.1", + "ip-address": "^10.7.4", "postcss": "^8.5.25", "@hono/node-server": "^2.1.0", "tmp": "^0.2.6" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/sdk": "^1.32.0", "ajv": "^8.17.1", "express": "^5.1.0", "express-rate-limit": "^8.3.2", diff --git a/scripts/bench-tool-input-elements.ts b/scripts/bench-tool-input-elements.ts new file mode 100644 index 00000000..9928ef7a --- /dev/null +++ b/scripts/bench-tool-input-elements.ts @@ -0,0 +1,160 @@ +/* + * What one tool call's arguments cost the server, with and without the + * element limit (security.maxToolInputElements, GHSA-46jr-4wx8-v453). + * + * node --import tsx scripts/bench-tool-input-elements.ts + * node --import tsx scripts/bench-tool-input-elements.ts --limits off,10000,100000 --concurrency 20 --runs 3 + * + * For each limit and each payload, starts a fresh HTTP MCP server in a + * child process (the real createIrisServer and createHttpTransport), opens + * --concurrency sessions, and has each send the same log_trace call at + * once. The payload is the largest of its shape that fits the 1 MB request + * size limit and stays within the element limit: a call over the limit is + * refused before its arguments are validated, so the worst case is one + * just under it. The shapes are `spans` of zeros and of empty objects, + * which fail validation once and twice per element. Just under is not + * always the worst: past roughly 100,000 validation errors the SDK's error + * message overflows the stack and stops early, so under a limit of 100,000 + * the costliest payload found was 60,000 empty objects (530 to 690 MB and + * 9 to 11 s for 20 at once, across runs); `--elements 60000` sends that size. + * + * Reported: the server's peak RSS above its level after garbage collection + * (sampled every millisecond from a worker thread, because the main thread + * is blocked while it validates), the time until every request has been + * answered, and what came back. Each figure is the median of --runs. + * `off` is the SDK's own default, no limit. Everything lives in a + * temporary directory, IRIS_HOME included, removed at the end. + */ +import { fork } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { cpus, platform, release, tmpdir, totalmem } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Worker } from 'node:worker_threads'; + +const args = process.argv.slice(2); +const opt = (name: string, fallback: string) => { + const i = args.indexOf(`--${name}`); + return i >= 0 ? args[i + 1] : fallback; +}; + +if (args[0] === '--serve') { + // The child: one Iris HTTP MCP server, and a sampler of this process's RSS. + const limit = args[1] === 'off' ? Infinity : Number(args[1]); + const { createIrisServer } = await import('../src/server.js'); + const { createHttpTransport } = await import('../src/transport/http.js'); + const { SqliteAdapter } = await import('../src/storage/sqlite-adapter.js'); + const { defaultConfig } = await import('../src/config/defaults.js'); + const storage = new SqliteAdapter(':memory:'); + await storage.initialize(); + const config = { + ...defaultConfig, + transport: { ...defaultConfig.transport, type: 'http' as const, port: 0 }, + security: { + ...defaultConfig.security, + // Infinity is the SDK's "no limit"; the config file cannot say it, a benchmark can. + maxToolInputElements: limit, + rateLimit: { ...defaultConfig.security.rateLimit, mcp: 1_000_000 }, + }, + }; + const quiet = { debug() {}, info() {}, warn() {}, error() {} }; + const { newMcpServer } = createIrisServer(config, storage, undefined, { warn: () => {} }); + const http = await createHttpTransport(newMcpServer, config, quiet); + const peak = new BigInt64Array(new SharedArrayBuffer(8)); + new Worker( + `const { workerData } = require('node:worker_threads'); + const peak = new BigInt64Array(workerData); + setInterval(() => { const r = BigInt(process.memoryUsage.rss()); if (r > Atomics.load(peak, 0)) Atomics.store(peak, 0, r); }, 1);`, + { eval: true, workerData: peak.buffer }, + ).unref(); + const gc = (globalThis as { gc?: () => void }).gc; + process.on('message', (m) => { + if (m === 'reset') { + gc?.(); + gc?.(); + const base = process.memoryUsage.rss(); + Atomics.store(peak, 0, BigInt(base)); + process.send!({ base }); + } + if (m === 'report') process.send!({ peak: Number(Atomics.load(peak, 0)) }); + }); + process.send!({ port: (http.httpServer.address() as { port: number }).port }); +} else { + const LIMITS = opt('limits', 'off,10000,50000,100000').split(','); + const CONCURRENCY = Number(opt('concurrency', '20')); + const RUNS = Number(opt('runs', '3')); + const REQUEST_BYTES = 1024 * 1024; + const SHAPES = { zeros: '0', objects: '{}' } as const; + const H = { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }; + const home = mkdtempSync(join(tmpdir(), 'iris-bench-elements-')); + const self = fileURLToPath(import.meta.url); + + const body = (unit: string, n: number) => + `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"log_trace","arguments":{"agent_name":"a","spans":[${Array(n).fill(unit).join(',')}]}}}`; + /** Elements in the payload: the largest that fits the request size limit, and for a limit, two under it (agent_name and spans are the other two). */ + const elementsFor = (unit: string, limit: string) => { + const fixed = Buffer.byteLength(body(unit, 0)); + const fits = Math.floor((REQUEST_BYTES - fixed + 1) / (unit.length + 1)); + const asked = args.includes('--elements') ? Number(opt('elements', '0')) : Infinity; + return Math.min(fits, asked, limit === 'off' ? Infinity : Number(limit) - 2); + }; + + async function once1(limit: string, unit: string, n: number) { + const child = fork(self, ['--serve', limit], { execArgv: ['--expose-gc', '--import', 'tsx'], env: { ...process.env, IRIS_HOME: home } }); + const next = () => once(child, 'message').then(([m]) => m as Record); + const { port } = await next(); + const url = `http://127.0.0.1:${port}/mcp`; + const sessions: string[] = []; + for (let i = 0; i < CONCURRENCY; i++) { + const init = await fetch(url, { method: 'POST', headers: H, body: JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'bench', version: '1' } } }) }); + const sid = init.headers.get('mcp-session-id')!; + await init.text(); + await (await fetch(url, { method: 'POST', headers: { ...H, 'mcp-session-id': sid }, body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) })).text(); + sessions.push(sid); + } + const payload = body(unit, n); + child.send('reset'); + const { base } = await next(); + const t0 = performance.now(); + const answers = await Promise.all( + sessions.map(async (sid) => { + try { + const r = await fetch(url, { method: 'POST', headers: { ...H, 'mcp-session-id': sid }, body: payload }); + const text = await r.text(); + return `${r.status}${/"isError":true/.test(text) ? ' isError' : ''}`; + } catch (err) { + return `failed: ${(err as { cause?: { code?: string } }).cause?.code ?? String(err)}`; + } + }), + ); + const ms = performance.now() - t0; + let peakMb = NaN; + if (child.exitCode === null && child.signalCode === null) { + child.send('report'); + peakMb = ((await next()).peak - base) / 1048576; + } + child.kill(); + return { ms, peakMb, answers: [...new Set(answers)].join(', ') }; + } + + const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)]; + console.log(`# ${platform()} ${release()}, ${cpus()[0]?.model ?? 'cpu'} x${cpus().length}, ${Math.round(totalmem() / 2 ** 30)} GB, Node ${process.version}`); + console.log(`# ${CONCURRENCY} sessions each send the same log_trace call at once; median of ${RUNS} runs\n`); + console.log('| limit | spans of | elements | request bytes | peak RSS over baseline | answered all in | answers |'); + console.log('|---|---|---:|---:|---:|---:|---|'); + try { + for (const limit of LIMITS) { + for (const [shape, unit] of Object.entries(SHAPES)) { + const n = elementsFor(unit, limit); + const runs = []; + for (let i = 0; i < RUNS; i++) runs.push(await once1(limit, unit, n)); + const peak = median(runs.map((r) => r.peakMb)); + const ms = median(runs.map((r) => r.ms)); + console.log(`| ${limit} | ${shape} | ${n.toLocaleString('en-US')} | ${Buffer.byteLength(body(unit, n)).toLocaleString('en-US')} | ${Number.isNaN(peak) ? 'server died' : `${Math.round(peak)} MB`} | ${(ms / 1000).toFixed(1)} s | ${runs[0].answers} |`); + } + } + } finally { + rmSync(home, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); + } +} diff --git a/server.json b/server.json index 13d20942..7def2314 100644 --- a/server.json +++ b/server.json @@ -223,6 +223,13 @@ "isSecret": false, "name": "IRIS_ALLOWED_ORIGINS" }, + { + "description": "The most array elements and object keys one tool call's arguments may hold (default 100000); a call over it gets an error result before its arguments are validated", + "isRequired": false, + "format": "number", + "isSecret": false, + "name": "IRIS_MAX_TOOL_INPUT_ELEMENTS" + }, { "description": "Bind host for the dashboard (default 127.0.0.1); same as --dashboard-host", "isRequired": false, diff --git a/src/config/defaults.ts b/src/config/defaults.ts index c7745425..559b71f8 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -16,6 +16,36 @@ try { // instead of hardcoding a literal (OTel resource attrs, health, banners). export const PKG_VERSION = pkgVersion; +/* + * The most array elements and object keys one tool call's arguments may + * hold (security.maxToolInputElements). The MCP SDK counts them before it + * validates the arguments, and refuses a call over the limit with an + * isError result (GHSA-46jr-4wx8-v453); the SDK leaves the count unlimited + * unless the server sets one. + * + * The bytes were already bounded (requestSizeLimit, 1 MB on both + * transports); the element count was not, and it is what costs memory. + * Arguments that fail validation are reported element by element, 63 to + * 148 characters of error text each, so one 1 MB request of 524,229 bad + * elements cost the server 260 MB, and 20 requests of 1 MB at once (the + * MCP endpoint's per-minute rate limit) up to 1,148 MB and 41 s to answer. + * + * The number is the first power of ten above every legitimate input + * measured, even one that fills the whole 1 MB request. The largest tool + * call in the test suite holds 39 elements, the largest stored case (a + * proof corpus trace of 201 tool calls) 1,009. Scaled up to 1 MB, real + * agent transcripts hold 12,615, OpenTelemetry spans exported by agent + * frameworks up to 14,889, Iris's own tools/list 34,474, and that + * 201-call case, the densest shape found, 68,506. At 100,000 the worst + * burst measured cost 530 to 690 MB across runs, and 9 to 11 s. An + * operator who exposes HTTP beyond loopback can go lower: 10,000 holds the + * same burst to about 250 MB and 1 s, and refuses a trace of real agent + * shape somewhere past 700 KB. An operator who raises requestSizeLimit for + * denser data raises this with it. The bursts are measured by + * scripts/bench-tool-input-elements.ts. + */ +export const DEFAULT_MAX_TOOL_INPUT_ELEMENTS = 100_000; + export const defaultConfig: IrisConfig = { storage: { type: 'sqlite', @@ -133,5 +163,6 @@ export const defaultConfig: IrisConfig = { mcp: 20, }, requestSizeLimit: '1mb', + maxToolInputElements: DEFAULT_MAX_TOOL_INPUT_ELEMENTS, }, }; diff --git a/src/config/index.ts b/src/config/index.ts index f79320c7..26a8be23 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -5,7 +5,7 @@ import { assertWebhookConfig } from '../notify/config.js'; import { defaultConfig } from './defaults.js'; import { assertValidCriticality } from '../eval/criticality.js'; import { irisHome } from '../utils/iris-home.js'; -import { validateConfigFile, SEARCH_BUDGET_RANGE_MS } from './schema.js'; +import { validateConfigFile, SEARCH_BUDGET_RANGE_MS, TOOL_INPUT_ELEMENTS_RULE } from './schema.js'; import { setPricingSettings } from '../cost/model-lookup.js'; /* @@ -100,6 +100,15 @@ function parseSearchBudgetEnv(value: string): number { return n; } +/** IRIS_MAX_TOOL_INPUT_ELEMENTS: what config.json's security.maxToolInputElements accepts; anything else refuses startup. */ +function parseToolInputElementsEnv(value: string): number { + const n = /^\s*\d+\s*$/.test(value) ? Number(value) : NaN; + if (!Number.isSafeInteger(n) || n < 1) { + throw new Error(`IRIS_MAX_TOOL_INPUT_ELEMENTS=${JSON.stringify(value)} is not valid (must be ${TOOL_INPUT_ELEMENTS_RULE})`); + } + return n; +} + /** IRIS_SEARCH_INDEX: `on` or `off`, as config.json's storage.searchIndex; anything else refuses startup. */ function parseSearchIndexEnv(value: string): 'on' | 'off' { const v = value.trim().toLowerCase(); @@ -193,6 +202,12 @@ function loadEnvVars(): Partial { allowedOrigins: process.env.IRIS_ALLOWED_ORIGINS.split(',').map((s) => s.trim()), }; } + if (process.env.IRIS_MAX_TOOL_INPUT_ELEMENTS) { + config.security = { + ...(config.security as object), + maxToolInputElements: parseToolInputElementsEnv(process.env.IRIS_MAX_TOOL_INPUT_ELEMENTS), + }; + } return config as Partial; } diff --git a/src/config/schema.ts b/src/config/schema.ts index faab1526..f86ed971 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -31,6 +31,8 @@ import { parseSizeLimit } from '../utils/size-limit.js'; * waiting behind it are not left for minutes (#703). */ export const SEARCH_BUDGET_RANGE_MS = [50, 60_000] as const; +/** What security.maxToolInputElements and IRIS_MAX_TOOL_INPUT_ELEMENTS accept. */ +export const TOOL_INPUT_ELEMENTS_RULE = 'a whole number of at least 1, e.g. 100000'; const port = z.number().int().min(1).max(65535); const nonNegativeInt = z.number().int().min(0); const nonNegative = z.number().min(0); @@ -150,6 +152,11 @@ export const configFileSchema = z.strictObject({ requestSizeLimit: name .refine((v) => parseSizeLimit(v) !== null, 'a size: a number of bytes, or a number with kb, mb or gb, e.g. "1mb"') .optional(), + maxToolInputElements: z + .number() + .int(TOOL_INPUT_ELEMENTS_RULE) + .min(1, TOOL_INPUT_ELEMENTS_RULE) + .optional(), }) .optional(), }); diff --git a/src/index.ts b/src/index.ts index 4f680a5c..cec0446f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -266,6 +266,9 @@ Environment variables (CLI flags take precedence): IRIS_ALLOWED_ORIGINS Comma-separated origin allowlist. Dashboard: CORS headers (supports globs, e.g. http://localhost:*). HTTP transport: exact-match Origin allowlist for DNS-rebinding protection (globs ignored; this server's own loopback origins are always allowed). + IRIS_MAX_TOOL_INPUT_ELEMENTS The most array elements and object keys one tool call's arguments may hold, on every + transport (default: 100000); a call over it gets an error result before its arguments + are validated. Also security.maxToolInputElements in config.json IRIS_NO_AUTO_LAUNCH Set to 1 to disable first-run dashboard auto-launch IRIS_ANTHROPIC_API_KEY Required by evaluate_with_llm_judge + verify_citations (provider=anthropic) IRIS_OPENAI_API_KEY Required by evaluate_with_llm_judge + verify_citations (provider=openai) diff --git a/src/server.ts b/src/server.ts index 108ff047..58d9e4eb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,6 +17,7 @@ import { judgeBudgetFromEnv } from './eval/llm-judge/budget.js'; import type { StoreGate } from './storage/ready.js'; import { errorResult } from './tools/respond.js'; import { toIrisError } from './tools/errors.js'; +import { DEFAULT_MAX_TOOL_INPUT_ELEMENTS } from './config/defaults.js'; export interface IrisServer { mcpServer: McpServer; @@ -130,13 +131,25 @@ export function createIrisServer( const capabilities = (): Capabilities => buildCapabilities({ config, evalEngine, customRuleStore: ruleStore, mode: options?.mode }); + /* + * The element limit (GHSA-46jr-4wx8-v453). The SDK counts a tool call's + * array elements and object keys before it validates the arguments, and + * answers a call over the limit with an isError result. It applies no + * limit unless the server sets one, so every MCP server built here sets + * it, for stdio and HTTP alike: one limit everywhere, as with + * requestSizeLimit. The default and the measurements behind it are in + * src/config/defaults.ts. A config built without the key (an embedder's) + * still gets the default. + */ + const maxToolInputElements = config.security.maxToolInputElements ?? DEFAULT_MAX_TOOL_INPUT_ELEMENTS; + const newMcpServer = (): McpServer => { const server = new McpServer( { name: config.server.name, version: config.server.version, }, - { instructions }, + { instructions, maxToolInputElements }, ); if (options?.gate) gateRequests(server, options.gate); registerAllTools(server, storage, evalEngine, ruleStore); diff --git a/src/types/config.ts b/src/types/config.ts index cad67aad..d7cc215f 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -275,5 +275,13 @@ export interface IrisConfig { mcpKeyBy?: 'ip' | 'apiKey'; }; requestSizeLimit: string; + /** + * The most array elements and object keys one tool call's arguments + * may hold, on every transport (IRIS_MAX_TOOL_INPUT_ELEMENTS). A call + * over it gets an isError result before its arguments are validated. + * Default 100,000: see DEFAULT_MAX_TOOL_INPUT_ELEMENTS in + * src/config/defaults.ts for the measurements behind it. + */ + maxToolInputElements: number; }; } diff --git a/tests/integration/tool-input-elements.test.ts b/tests/integration/tool-input-elements.test.ts new file mode 100644 index 00000000..4f87df01 --- /dev/null +++ b/tests/integration/tool-input-elements.test.ts @@ -0,0 +1,191 @@ +/* + * The tool-input element limit (security.maxToolInputElements). + * + * The MCP SDK can count the array elements and object keys in a tool + * call's arguments before it validates them, and answer a call over a + * limit with an isError result (GHSA-46jr-4wx8-v453). It counts nothing + * unless the server sets the limit, and an argument that fails validation + * is reported element by element, so one 1 MB request could cost the server + * hundreds of megabytes. Every MCP server Iris builds now sets it: the one + * stdio serves and each one an HTTP session gets. + * + * Held here: a call over the limit is answered, not crashed on, and never + * reaches the tool; the session keeps serving; a call at the limit passes; + * the default clears the densest legitimate trace shape measured even when + * it fills the whole 1 MB request; and an embedder's config without the + * key still gets the default. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import type { Server } from 'node:http'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { SqliteAdapter } from '../../src/storage/sqlite-adapter.js'; +import { createIrisServer } from '../../src/server.js'; +import { createHttpTransport } from '../../src/transport/http.js'; +import { DEFAULT_MAX_TOOL_INPUT_ELEMENTS, defaultConfig } from '../../src/config/defaults.js'; +import { requestSizeLimitBytes } from '../../src/utils/size-limit.js'; +import { LOCAL_TENANT } from '../../src/types/tenant.js'; +import type { IrisConfig } from '../../src/types/config.js'; +import { textOf, type ToolResult } from '../helpers/mcp-results.js'; + +/** Array elements plus object keys, counted the way the SDK counts them (toolInputElementCount in its server/mcp.js). */ +function elements(value: unknown): number { + let n = 0; + const stack: unknown[] = [value]; + while (stack.length > 0) { + const node = stack.pop(); + if (node === null || typeof node !== 'object') continue; + const children = Array.isArray(node) ? node : Object.values(node); + n += children.length; + for (const child of children) if (child !== null && typeof child === 'object') stack.push(child); + } + return n; +} + +/** A log_trace call of `calls` tool calls, each a read with a short output: the densest legitimate shape measured (5 elements a call, plus 3). */ +function trace(calls: number) { + return { + agent_name: 'bulk-agent', + output: 'Done.', + tool_calls: Array.from({ length: calls }, (_, i) => ({ tool_name: 'read_file', input: { path: `src/f${i}.ts` }, output: 'contents' })), + }; +} + +const withLimit = (maxToolInputElements: number): IrisConfig => ({ + ...defaultConfig, + security: { ...defaultConfig.security, maxToolInputElements }, +}); + +const refusal = (limit: number) => new RegExp(`arguments contain more than the maximum of ${limit} elements`); + +describe('the element limit on the MCP server stdio serves', () => { + const cleanup: Array<() => Promise> = []; + afterEach(async () => { + for (const fn of cleanup.splice(0).reverse()) await fn(); + }); + + async function connect(config: IrisConfig): Promise<{ client: Client; storage: SqliteAdapter }> { + const storage = new SqliteAdapter(':memory:'); + await storage.initialize(); + const { mcpServer } = createIrisServer(config, storage, undefined, { warn: () => {} }); + const [clientSide, serverSide] = InMemoryTransport.createLinkedPair(); + await mcpServer.connect(serverSide); + const client = new Client({ name: 'element-limit', version: '0.1.0' }); + await client.connect(clientSide); + cleanup.push(async () => { + await client.close(); + await storage.close(); + }); + return { client, storage }; + } + + const stored = async (storage: SqliteAdapter) => (await storage.queryTraces(LOCAL_TENANT, {})).total; + + it('answers a call over the limit with an isError result, before the tool runs, and keeps serving', async () => { + const { client, storage } = await connect(withLimit(50)); + expect(elements(trace(10))).toBe(53); + + const over = (await client.callTool({ name: 'log_trace', arguments: trace(10) })) as ToolResult; + expect(over.isError).toBe(true); + expect(textOf(over)).toMatch(refusal(50)); + expect(await stored(storage)).toBe(0); + + const under = (await client.callTool({ name: 'log_trace', arguments: trace(9) })) as ToolResult; + expect(under.isError).toBeFalsy(); + expect(await stored(storage)).toBe(1); + }); + + it('passes a call of exactly the limit, and refuses one element more', async () => { + const exact = elements(trace(9)); + expect((await (await connect(withLimit(exact))).client.callTool({ name: 'log_trace', arguments: trace(9) })).isError).toBeFalsy(); + const refused = (await (await connect(withLimit(exact - 1))).client.callTool({ name: 'log_trace', arguments: trace(9) })) as ToolResult; + expect(refused.isError).toBe(true); + expect(textOf(refused)).toMatch(refusal(exact - 1)); + }); + + it('at the default, stores the densest legitimate trace shape measured, filled to the whole 1 MB request size limit', async () => { + const { client, storage } = await connect(defaultConfig); + // The most such calls one request within security.requestSizeLimit can carry. + const limitBytes = requestSizeLimitBytes(defaultConfig.security.requestSizeLimit); + const request = (calls: number) => + Buffer.byteLength(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'log_trace', arguments: trace(calls) } })); + let calls = Math.floor(limitBytes / 80); + while (request(calls + 100) <= limitBytes) calls += 100; + const args = trace(calls); + expect(request(calls)).toBeLessThanOrEqual(limitBytes); + // About 67,000 here (the paths lengthen as they count up); the proof corpus case it copies reaches 68,506 at 1 MB. + expect(elements(args)).toBeGreaterThan(60_000); + expect(elements(args)).toBeLessThan(DEFAULT_MAX_TOOL_INPUT_ELEMENTS); + + const result = (await client.callTool({ name: 'log_trace', arguments: args })) as ToolResult; + expect(result.isError).toBeFalsy(); + expect(await stored(storage)).toBe(1); + }, 30_000); + + it('at the default, refuses a payload built to be dense before validating it', async () => { + const { client, storage } = await connect(defaultConfig); + expect(DEFAULT_MAX_TOOL_INPUT_ELEMENTS).toBe(100_000); + // 100,001 zeros where spans are expected: 200 KB, and one validation error each without the limit. + const result = (await client.callTool({ + name: 'log_trace', + arguments: { agent_name: 'a', output: 'x', spans: Array(DEFAULT_MAX_TOOL_INPUT_ELEMENTS + 1).fill(0) }, + })) as ToolResult; + expect(result.isError).toBe(true); + expect(textOf(result)).toMatch(refusal(DEFAULT_MAX_TOOL_INPUT_ELEMENTS)); + expect(await stored(storage)).toBe(0); + }); + + it('gives a config built without the key (an embedder\'s) the default', async () => { + const security: Partial = { ...defaultConfig.security }; + delete security.maxToolInputElements; + const { client } = await connect({ ...defaultConfig, security } as IrisConfig); + const result = (await client.callTool({ + name: 'log_trace', + arguments: { agent_name: 'a', output: 'x', spans: Array(DEFAULT_MAX_TOOL_INPUT_ELEMENTS + 1).fill(0) }, + })) as ToolResult; + expect(result.isError).toBe(true); + expect(textOf(result)).toMatch(refusal(DEFAULT_MAX_TOOL_INPUT_ELEMENTS)); + }); +}); + +describe('the element limit on the MCP servers the HTTP transport serves', () => { + const logger = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }; + let httpServer: Server | undefined; + let storage: SqliteAdapter | undefined; + + afterEach(async () => { + if (httpServer) { + httpServer.closeAllConnections?.(); + await new Promise((resolve) => httpServer!.close(() => resolve())); + httpServer = undefined; + } + await storage?.close(); + storage = undefined; + }); + + it('answers a call over the limit with an isError result, and the session goes on', async () => { + const config: IrisConfig = { + ...withLimit(50), + transport: { ...defaultConfig.transport, type: 'http', host: '127.0.0.1', port: 0 }, + }; + storage = new SqliteAdapter(':memory:'); + await storage.initialize(); + const { newMcpServer } = createIrisServer(config, storage, undefined, { warn: () => {} }); + const http = await createHttpTransport(newMcpServer, config, logger); + httpServer = http.httpServer; + const url = new URL(`http://127.0.0.1:${(httpServer.address() as { port: number }).port}/mcp`); + const client = new Client({ name: 'element-limit-http', version: '0.1.0' }); + await client.connect(new StreamableHTTPClientTransport(url)); + + const over = (await client.callTool({ name: 'log_trace', arguments: trace(10) })) as ToolResult; + expect(over.isError).toBe(true); + expect(textOf(over)).toMatch(refusal(50)); + + const under = (await client.callTool({ name: 'log_trace', arguments: trace(9) })) as ToolResult; + expect(under.isError).toBeFalsy(); + expect((await storage.queryTraces(LOCAL_TENANT, {})).total).toBe(1); + await client.close(); + await http.closeSessions(); + }); +}); diff --git a/tests/unit/config-tool-input-elements.test.ts b/tests/unit/config-tool-input-elements.test.ts new file mode 100644 index 00000000..319dd634 --- /dev/null +++ b/tests/unit/config-tool-input-elements.test.ts @@ -0,0 +1,73 @@ +/* + * The tool-input element limit is set by security.maxToolInputElements in + * config.json or IRIS_MAX_TOOL_INPUT_ELEMENTS: 100,000 unless set, the + * environment wins over the file, and a value that is not a whole number of + * at least 1 refuses startup naming the setting, so a typo cannot quietly + * mean "no limit". What the limit does to a tool call is held in + * tests/integration/tool-input-elements.test.ts. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { loadConfig } from '../../src/config/index.js'; +import { DEFAULT_MAX_TOOL_INPUT_ELEMENTS, defaultConfig } from '../../src/config/defaults.js'; + +const VARS = ['IRIS_HOME', 'IRIS_DB_PATH', 'IRIS_MAX_TOOL_INPUT_ELEMENTS'] as const; +let scratch: string; +let home: string; +const saved: Record = {}; + +beforeEach(() => { + for (const v of VARS) saved[v] = process.env[v]; + scratch = mkdtempSync(join(tmpdir(), 'iris-config-tool-input-elements-')); + home = join(scratch, 'home'); + mkdirSync(home, { recursive: true }); + process.env.IRIS_HOME = home; + delete process.env.IRIS_DB_PATH; + delete process.env.IRIS_MAX_TOOL_INPUT_ELEMENTS; +}); + +afterEach(() => { + for (const v of VARS) { + if (saved[v] === undefined) delete process.env[v]; + else process.env[v] = saved[v]; + } + rmSync(scratch, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); +}); + +const writeConfig = (value: unknown) => writeFileSync(join(home, 'config.json'), JSON.stringify(value)); + +describe('the tool-input element limit setting', () => { + it('is 100,000 unless set', () => { + expect(DEFAULT_MAX_TOOL_INPUT_ELEMENTS).toBe(100_000); + expect(defaultConfig.security.maxToolInputElements).toBe(100_000); + expect(loadConfig().security.maxToolInputElements).toBe(100_000); + }); + + it('reads config.json, and the environment over it', () => { + writeConfig({ security: { maxToolInputElements: 10_000 } }); + expect(loadConfig().security.maxToolInputElements).toBe(10_000); + process.env.IRIS_MAX_TOOL_INPUT_ELEMENTS = '250000'; + expect(loadConfig().security.maxToolInputElements).toBe(250_000); + }); + + it('refuses a value that is not a whole number of at least 1, naming the setting', () => { + for (const bad of ['0', '-1', '1.5', '1e5', 'lots', '10,000']) { + process.env.IRIS_MAX_TOOL_INPUT_ELEMENTS = bad; + expect(() => loadConfig(), bad).toThrow( + `IRIS_MAX_TOOL_INPUT_ELEMENTS=${JSON.stringify(bad)} is not valid (must be a whole number of at least 1, e.g. 100000)`, + ); + } + delete process.env.IRIS_MAX_TOOL_INPUT_ELEMENTS; + for (const bad of [0, -5, 2.5, '100000', null]) { + writeConfig({ security: { maxToolInputElements: bad } }); + expect(() => loadConfig(), String(bad)).toThrow(/security\.maxToolInputElements/); + } + for (const good of ['1', ' 50000 ']) { + process.env.IRIS_MAX_TOOL_INPUT_ELEMENTS = good; + writeConfig({}); + expect(loadConfig().security.maxToolInputElements).toBe(Number(good)); + } + }); +}); diff --git a/tests/unit/middleware/auth.test.ts b/tests/unit/middleware/auth.test.ts index 5befb934..3411bd1f 100644 --- a/tests/unit/middleware/auth.test.ts +++ b/tests/unit/middleware/auth.test.ts @@ -12,6 +12,7 @@ function makeConfig(apiKey?: string): Pick { allowedOrigins: ['http://localhost:*'], rateLimit: { api: 100, mcp: 20 }, requestSizeLimit: '1mb', + maxToolInputElements: 100_000, }, }; } @@ -112,6 +113,7 @@ describe('auth middleware — the key ring', () => { allowedOrigins: ['http://localhost:*'], rateLimit: { api: 100, mcp: 20 }, requestSizeLimit: '1mb', + maxToolInputElements: 100_000, }, }; } diff --git a/tests/unit/middleware/rate-limit.test.ts b/tests/unit/middleware/rate-limit.test.ts index a03088e4..c0ce7ee7 100644 --- a/tests/unit/middleware/rate-limit.test.ts +++ b/tests/unit/middleware/rate-limit.test.ts @@ -16,6 +16,7 @@ function makeConfig(apiLimit: number, mcpLimit = 20): Pick