From 1579a7f24c1ae53c191ecf7913a2400400aadbf4 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 14 Aug 2026 13:46:23 +0300 Subject: [PATCH 01/10] =?UTF-8?q?spec:=20096-batched-call-tools=20?= =?UTF-8?q?=E2=80=94=20batched=20call=5Ftools()=20for=20parallel=20upstrea?= =?UTF-8?q?m=20calls=20in=20the=20code-exec=20sandbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #987 --- .../checklists/requirements.md | 35 +++++ specs/096-batched-call-tools/spec.md | 131 ++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 specs/096-batched-call-tools/checklists/requirements.md create mode 100644 specs/096-batched-call-tools/spec.md diff --git a/specs/096-batched-call-tools/checklists/requirements.md b/specs/096-batched-call-tools/checklists/requirements.md new file mode 100644 index 00000000..d07e7536 --- /dev/null +++ b/specs/096-batched-call-tools/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Batched call_tools() for Parallel Upstream Calls + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-08-14 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- The sandbox's ES5.1/timer-free nature and the JSON wire shape are user-visible contract facts of the existing product, not implementation choices, so naming them in the spec is deliberate. +- Budget-exhaustion semantics (per-slot over-budget errors in input order) chosen over whole-batch rejection to keep FR-002's no-short-circuit property uniform; documented as an edge case. diff --git a/specs/096-batched-call-tools/spec.md b/specs/096-batched-call-tools/spec.md new file mode 100644 index 00000000..a6dda9c9 --- /dev/null +++ b/specs/096-batched-call-tools/spec.md @@ -0,0 +1,131 @@ +# Feature Specification: Batched call_tools() for Parallel Upstream Calls in the Code-Execution Sandbox + +**Feature Branch**: `096-batched-call-tools` +**Created**: 2026-08-14 +**Status**: Draft +**Input**: User description: "Batched call_tools() host primitive so independent upstream calls run in parallel inside the code-execution JS sandbox (GitHub issue #987)" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Fan-out orchestration completes in parallel time (Priority: P1) + +An AI agent uses the code-execution sandbox to orchestrate many independent upstream tool calls in one request — fetch 20 pull requests, read 15 issues, poll 8 servers. Today each `call_tool()` is strictly serial, so the run costs the *sum* of the upstream latencies (a measured run made 31 upstream calls in 20.6 seconds, nearly all of it waiting on I/O with no ordering dependency). With `call_tools()`, the script hands the whole independent set to the host in one call and receives all results at once, so the run costs roughly the *slowest* call, not the sum. + +**Why this priority**: Fan-out is the headline use case the code-execution feature exists for, and it is the pattern that pays the most under serial execution. Without this story there is no feature. + +**Independent Test**: Run a script that issues N independent calls against a stub upstream with a fixed per-call delay, once as a `call_tool()` loop and once as one `call_tools()` batch; the batched run completes in a fraction of the serial time and returns identical per-call results in input order. + +**Acceptance Scenarios**: + +1. **Given** a connected upstream server whose tool responds in ~300ms, **When** a script calls `call_tools()` with 10 independent requests to it, **Then** all 10 results arrive in input order, each carrying the same envelope a lone `call_tool()` would return, and the batch completes in significantly less time than 10 serial calls. +2. **Given** a batch whose elements target several different servers, **When** the batch runs, **Then** every element executes with the same scope, approval, and logging treatment it would receive as an individual `call_tool()`. +3. **Given** an empty array, **When** `call_tools([])` is called, **Then** it returns an empty array immediately and consumes none of the execution's tool-call budget. + +--- + +### User Story 2 - One failure never poisons the batch (Priority: P2) + +A script fans out 20 calls; one target server is down and one element references a tool that does not exist. The batch still returns 20 slots in input order: 18 carry `{ok: true, result}`, the 2 failures carry `{ok: false, error}` with the same error codes a lone `call_tool()` would produce. The script inspects each slot and proceeds with the successes. + +**Why this priority**: Per-slot error isolation is the reason to prefer an explicit batch API over implicit parallelism; without it a single flaky upstream destroys whole-batch results and the primitive is unusable for real fan-out. + +**Independent Test**: Build a batch mixing valid elements, an unknown server, an unknown tool, and a scope-violating element; assert every slot resolves, failures carry per-slot errors, and successes are unaffected. + +**Acceptance Scenarios**: + +1. **Given** a batch of 5 where element 3 targets a server that is not connected, **When** the batch runs, **Then** slots 1, 2, 4, 5 carry results, slot 3 carries `{ok: false, error}`, and no slot is missing or reordered. +2. **Given** a batch element that violates the execution's server scope (allowed_servers, profile, or agent-token scope), **When** the batch runs, **Then** that slot fails with the same error a lone `call_tool()` to that server would produce, and sibling slots are unaffected. + +--- + +### User Story 3 - Concurrency stays operator-controlled (Priority: P3) + +An operator caps how hard one sandbox execution may hammer upstreams. The batch runs at most `max_parallel` elements at once — from configuration by default, overridable per call downward or upward within the configured ceiling — and always honors the per-server concurrency and queueing limits that already govern individual calls (Spec 093). A batch can never become a way around those limits. + +**Why this priority**: Without a bound, one script could open unbounded concurrent upstream requests; without respecting per-server limits, the batch would bypass protections that individual calls obey. Both would make the feature unshippable, but the default bound makes this safe out of the box, so it ranks after the core semantics. + +**Independent Test**: Point a batch of 10 at a stub server that records concurrent-request high-water mark; with `max_parallel` 3 the high-water mark never exceeds 3; with a per-server concurrency limit of 1 configured, requests to that server serialize regardless of `max_parallel`. + +**Acceptance Scenarios**: + +1. **Given** a configured default `max_parallel`, **When** a batch larger than the bound runs, **Then** no more than `max_parallel` elements are in flight at any moment and all elements still complete. +2. **Given** a per-call `max_parallel` override within the permitted range, **When** the batch runs, **Then** the override governs; an override outside the permitted range is rejected before any element is dispatched. +3. **Given** a per-server concurrency limit lower than `max_parallel`, **When** a batch targets that server, **Then** the per-server limit governs those elements (the batch queues rather than bypasses). + +--- + +### Edge Cases + +- **Budget exhaustion mid-batch**: each element counts against the execution's `max_tool_calls` budget individually. When a batch is larger than the remaining budget, the elements within budget (in input order) execute and the excess slots fail with the same over-budget error a lone `call_tool()` would produce — no short-circuit, no partial slot. +- **Execution timeout during the batch**: the batch is bounded by the overall execution timeout; at the deadline the execution ends exactly as it would if a lone `call_tool()` were in flight. +- **Malformed input**: a non-array argument, or an element that is not an object with a `server` string and a `tool` string, rejects the whole call before any element is dispatched, with an error naming the offending element index. `args` is optional per element and defaults to an empty object. +- **Single-element batch**: behaves identically to `call_tool()` for that element, envelope included. +- **Duplicate elements**: permitted; each executes and is budgeted independently. +- **Non-JSON-serializable result**: an element whose upstream result cannot be represented in the documented wire shape fails that slot with the same serialization error a lone `call_tool()` produces; siblings are unaffected. +- **Sandbox character preserved**: the sandbox remains ES5.1 and timer-free; `call_tools()` is synchronous from the script's point of view (it returns the completed results array; there are no promises, callbacks, or timers). + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The code-execution sandbox MUST expose a `call_tools(requests, options?)` function accepting an array of `{server, tool, args?}` elements and returning an array of the same length, in input order, one slot per element. +- **FR-002**: Each result slot MUST carry exactly the envelope `call_tool()` returns — `{ok: true, result}` or `{ok: false, error: {code, message}}` — and a failing element MUST NOT affect any other slot (no short-circuiting). +- **FR-003**: Independent elements MUST execute concurrently such that a batch of N independent calls completes in wall-clock time bounded by the slowest element plus scheduling overhead, not the sum of element latencies, whenever N is within the concurrency bound. +- **FR-004**: Concurrency MUST be bounded by `max_parallel`: a configured default applies to every batch, and a per-call override is accepted within a permitted range; an out-of-range override MUST be rejected before any element is dispatched. +- **FR-005**: Batched elements MUST respect the same per-server concurrency and queueing limits that govern individual upstream calls (Spec 093); the batch MUST NOT provide any path around them. +- **FR-006**: Each element MUST count against the execution's `max_tool_calls` budget individually; elements beyond the remaining budget MUST fail per-slot with the same over-budget error as a lone `call_tool()`, without being dispatched upstream. +- **FR-007**: The batch MUST be bounded by the overall execution timeout, with no additional or extended deadline of its own. +- **FR-008**: Each slot's `result` MUST be presented in the same documented wire (JSON) shape as `call_tool()` results — never as live host values. +- **FR-009**: Every element MUST pass the same scope, permission, and approval enforcement as an individual `call_tool()` (allowed_servers, profile scope, agent-token scope, quarantine and approval gates), evaluated per element. +- **FR-010**: Every element MUST be recorded in tool-call history and activity logging exactly as an individual `call_tool()` would be, correlated to the same parent execution. +- **FR-011**: The sandbox MUST remain ES5.1 and timer-free; `call_tools()` MUST be synchronous from the script's perspective. +- **FR-012**: A malformed call — non-array argument, or an element lacking a `server` or `tool` string — MUST be rejected before any element is dispatched, with an error identifying the first offending element. +- **FR-013**: `call_tools([])` MUST return an empty array, consume no tool-call budget, and dispatch nothing. +- **FR-014**: The capability MUST be available wherever `call_tool()` is available today (every surface that executes sandbox code, in both editions), and the code-execution tool description MUST document `call_tools()` alongside `call_tool()`. + +### Key Entities + +- **Batch request element**: one intended upstream call — target server name, tool name, and optional arguments object. +- **Batch result slot**: the outcome for one element — success envelope with the wire-shaped result, or failure envelope with an error code and message; position matches the element's input position. +- **Concurrency bound (`max_parallel`)**: the maximum number of elements in flight at once — a configured default with a per-call override inside a permitted range; subordinate to per-server limits. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A batch of 10 independent calls to an upstream with uniform latency completes in under 35% of the time the same 10 calls take serially (measured against a stub upstream with fixed delay). +- **SC-002**: The 31-call fan-out scenario from the originating issue (~20.6s serial) completes in under 5 seconds when expressed as batches, on the same upstreams. +- **SC-003**: In a batch with any mix of failing elements (unreachable server, unknown tool, scope violation, over-budget), 100% of slots resolve — every failure is per-slot and every sibling success is intact. +- **SC-004**: With a per-server concurrency limit configured, a batch never exceeds that limit for that server's elements (observed concurrent-request high-water mark equals the limit), regardless of `max_parallel`. +- **SC-005**: Existing single-call scripts and all existing code-execution behavior are unchanged: the full existing test suite passes without modification (beyond additions). + +## Commit Message Conventions *(mandatory)* + +When committing changes for this feature, follow these guidelines: + +### Issue References +- ✅ **Use**: `Related #987` - Links the commit to the issue without auto-closing +- ❌ **Do NOT use**: `Fixes #987`, `Closes #987`, `Resolves #987` - These auto-close issues on merge + +**Rationale**: Issues should only be closed manually after verification and testing in production, not automatically on merge. + +### Co-Authorship +- ❌ **Do NOT include**: `Co-Authored-By: Claude ` +- ❌ **Do NOT include**: "🤖 Generated with [Claude Code](https://claude.com/claude-code)" + +**Rationale**: Commit authorship should reflect the human contributors, not the AI tools used. + +### Example Commit Message +``` +feat: add batched call_tools() to the code-execution sandbox + +Related #987 + +[Detailed description of what was changed and why] + +## Changes +- [Bulleted list of key changes] + +## Testing +- [Test results summary] +``` From ae52bff07cb6392b56425ced4866d0b6d46af389 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 14 Aug 2026 13:51:56 +0300 Subject: [PATCH 02/10] =?UTF-8?q?spec:=20096=20review=20pass=20=E2=80=94?= =?UTF-8?q?=20pin=20max=5Fparallel=20config,=20deterministic=20budget,=20c?= =?UTF-8?q?ancellation,=20parity-as-today=20semantics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #987 Resolves all 11 cross-model review findings: execution-context cancellation for batch workers, parity defined as call_tool's current enforcement (gate expansion out of scope), full max_parallel definition (code_execution_max_parallel, default 8, range 1-32, per-call override), input-order pre-dispatch checks so budget cannot race, envelope-based malformed-call rejection, batch cap 100, timeout semantics reconciled with SC-003, dropped the incorrect ES5.1 claim, stub-based reproducible SCs. --- specs/096-batched-call-tools/spec.md | 56 ++++++++++++++++------------ 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/specs/096-batched-call-tools/spec.md b/specs/096-batched-call-tools/spec.md index a6dda9c9..dc7dfe5b 100644 --- a/specs/096-batched-call-tools/spec.md +++ b/specs/096-batched-call-tools/spec.md @@ -18,7 +18,7 @@ An AI agent uses the code-execution sandbox to orchestrate many independent upst **Acceptance Scenarios**: 1. **Given** a connected upstream server whose tool responds in ~300ms, **When** a script calls `call_tools()` with 10 independent requests to it, **Then** all 10 results arrive in input order, each carrying the same envelope a lone `call_tool()` would return, and the batch completes in significantly less time than 10 serial calls. -2. **Given** a batch whose elements target several different servers, **When** the batch runs, **Then** every element executes with the same scope, approval, and logging treatment it would receive as an individual `call_tool()`. +2. **Given** a batch whose elements target several different servers, **When** the batch runs, **Then** every element executes with the same scope enforcement and logging treatment it would receive as an individual `call_tool()`. 3. **Given** an empty array, **When** `call_tools([])` is called, **Then** it returns an empty array immediately and consumes none of the execution's tool-call budget. --- @@ -34,13 +34,13 @@ A script fans out 20 calls; one target server is down and one element references **Acceptance Scenarios**: 1. **Given** a batch of 5 where element 3 targets a server that is not connected, **When** the batch runs, **Then** slots 1, 2, 4, 5 carry results, slot 3 carries `{ok: false, error}`, and no slot is missing or reordered. -2. **Given** a batch element that violates the execution's server scope (allowed_servers, profile, or agent-token scope), **When** the batch runs, **Then** that slot fails with the same error a lone `call_tool()` to that server would produce, and sibling slots are unaffected. +2. **Given** a batch element that violates the execution's server scope (caller allow-list, profile scope, or agent-token scope), **When** the batch runs, **Then** that slot fails with the same error code and attribution a lone `call_tool()` to that server produces today, and sibling slots are unaffected. --- ### User Story 3 - Concurrency stays operator-controlled (Priority: P3) -An operator caps how hard one sandbox execution may hammer upstreams. The batch runs at most `max_parallel` elements at once — from configuration by default, overridable per call downward or upward within the configured ceiling — and always honors the per-server concurrency and queueing limits that already govern individual calls (Spec 093). A batch can never become a way around those limits. +An operator caps how hard one sandbox execution may hammer upstreams. The batch runs at most `max_parallel` elements at once — from configuration by default, overridable per call within a fixed permitted range — and always honors the per-server concurrency and queueing limits that already govern individual calls (Spec 093). A batch can never become a way around those limits. **Why this priority**: Without a bound, one script could open unbounded concurrent upstream requests; without respecting per-server limits, the batch would bypass protections that individual calls obey. Both would make the feature unshippable, but the default bound makes this safe out of the box, so it ranks after the core semantics. @@ -48,57 +48,65 @@ An operator caps how hard one sandbox execution may hammer upstreams. The batch **Acceptance Scenarios**: -1. **Given** a configured default `max_parallel`, **When** a batch larger than the bound runs, **Then** no more than `max_parallel` elements are in flight at any moment and all elements still complete. -2. **Given** a per-call `max_parallel` override within the permitted range, **When** the batch runs, **Then** the override governs; an override outside the permitted range is rejected before any element is dispatched. +1. **Given** the configured default `max_parallel`, **When** a batch larger than the bound runs, **Then** no more than `max_parallel` elements are in flight at any moment and all elements still complete. +2. **Given** a per-call `max_parallel` override within the permitted range, **When** the batch runs, **Then** the override governs; an override outside the permitted range (or non-integer) is rejected before any element is dispatched. 3. **Given** a per-server concurrency limit lower than `max_parallel`, **When** a batch targets that server, **Then** the per-server limit governs those elements (the batch queues rather than bypasses). --- ### Edge Cases -- **Budget exhaustion mid-batch**: each element counts against the execution's `max_tool_calls` budget individually. When a batch is larger than the remaining budget, the elements within budget (in input order) execute and the excess slots fail with the same over-budget error a lone `call_tool()` would produce — no short-circuit, no partial slot. -- **Execution timeout during the batch**: the batch is bounded by the overall execution timeout; at the deadline the execution ends exactly as it would if a lone `call_tool()` were in flight. -- **Malformed input**: a non-array argument, or an element that is not an object with a `server` string and a `tool` string, rejects the whole call before any element is dispatched, with an error naming the offending element index. `args` is optional per element and defaults to an empty object. +- **Budget exhaustion**: pre-dispatch checks (shape validation, scope enforcement, budget) are evaluated deterministically in input order before any element is dispatched, so concurrency can never oversubscribe the budget. Elements beyond the remaining `max_tool_calls` budget resolve per-slot with the same over-budget error a lone `call_tool()` produces, and are never dispatched. +- **Execution timeout during the batch**: the execution fails as a whole with the same timeout outcome as a lone in-flight `call_tool()` today; the script never resumes, so no partial batch value is observable. All queued elements are abandoned and in-flight work is cancelled (see FR-007). +- **Malformed call**: a non-array argument; an element that is not an object with non-empty `server` and `tool` strings; a supplied `args` that is not an object; a sparse array hole; or a non-object/invalid `options` — any of these cause `call_tools()` to return a single `{ok: false, error}` envelope (the same shape a lone `call_tool()` uses for invalid arguments) identifying the first offending element index, with nothing dispatched and no budget consumed. Unknown `options` fields are ignored. - **Single-element batch**: behaves identically to `call_tool()` for that element, envelope included. - **Duplicate elements**: permitted; each executes and is budgeted independently. - **Non-JSON-serializable result**: an element whose upstream result cannot be represented in the documented wire shape fails that slot with the same serialization error a lone `call_tool()` produces; siblings are unaffected. -- **Sandbox character preserved**: the sandbox remains ES5.1 and timer-free; `call_tools()` is synchronous from the script's point of view (it returns the completed results array; there are no promises, callbacks, or timers). +- **Oversized batch**: batches longer than the fixed batch-size cap (FR-013) are rejected as malformed before any dispatch. +- **Sandbox character preserved**: the sandbox remains timer-free with no promises or event loop; `call_tools()` is synchronous from the script's point of view (it returns the completed results array). The supported JavaScript language level is unchanged by this feature. ## Requirements *(mandatory)* ### Functional Requirements -- **FR-001**: The code-execution sandbox MUST expose a `call_tools(requests, options?)` function accepting an array of `{server, tool, args?}` elements and returning an array of the same length, in input order, one slot per element. +- **FR-001**: The code-execution sandbox MUST expose a `call_tools(requests, options?)` function accepting an array of `{server, tool, args?}` elements (`args` defaults to an empty object) and returning an array of the same length, in input order, one slot per element. - **FR-002**: Each result slot MUST carry exactly the envelope `call_tool()` returns — `{ok: true, result}` or `{ok: false, error: {code, message}}` — and a failing element MUST NOT affect any other slot (no short-circuiting). - **FR-003**: Independent elements MUST execute concurrently such that a batch of N independent calls completes in wall-clock time bounded by the slowest element plus scheduling overhead, not the sum of element latencies, whenever N is within the concurrency bound. -- **FR-004**: Concurrency MUST be bounded by `max_parallel`: a configured default applies to every batch, and a per-call override is accepted within a permitted range; an out-of-range override MUST be rejected before any element is dispatched. +- **FR-004**: Concurrency MUST be bounded by `max_parallel`, defined as: a new configuration field `code_execution_max_parallel` (integer; default 8 when absent or 0; permitted range 1–32; identical in both editions; a changed value applies to executions that start after the change), overridable per call via `options.max_parallel` (integer; same 1–32 range; fractional, non-numeric, or out-of-range values reject the whole call as malformed before any dispatch). - **FR-005**: Batched elements MUST respect the same per-server concurrency and queueing limits that govern individual upstream calls (Spec 093); the batch MUST NOT provide any path around them. -- **FR-006**: Each element MUST count against the execution's `max_tool_calls` budget individually; elements beyond the remaining budget MUST fail per-slot with the same over-budget error as a lone `call_tool()`, without being dispatched upstream. -- **FR-007**: The batch MUST be bounded by the overall execution timeout, with no additional or extended deadline of its own. +- **FR-006**: Each dispatched element MUST count against the execution's `max_tool_calls` budget individually, with budget evaluated deterministically in input order during pre-dispatch checks (before any element is dispatched). Elements beyond the remaining budget MUST resolve per-slot with the same over-budget error as a lone `call_tool()`, without being dispatched. Elements rejected pre-dispatch (scope, shape, budget) MUST NOT consume budget — matching what a lone `call_tool()` consumes today for the same failure. +- **FR-007**: The batch MUST be bounded by the overall execution timeout with no additional deadline of its own. Batch workers MUST run under the execution's lifetime: at the execution deadline, queued elements are not dispatched, in-flight upstream calls are cancelled, and no batch worker may mutate execution state (records, logs, results) after the execution has returned. - **FR-008**: Each slot's `result` MUST be presented in the same documented wire (JSON) shape as `call_tool()` results — never as live host values. -- **FR-009**: Every element MUST pass the same scope, permission, and approval enforcement as an individual `call_tool()` (allowed_servers, profile scope, agent-token scope, quarantine and approval gates), evaluated per element. -- **FR-010**: Every element MUST be recorded in tool-call history and activity logging exactly as an individual `call_tool()` would be, correlated to the same parent execution. -- **FR-011**: The sandbox MUST remain ES5.1 and timer-free; `call_tools()` MUST be synchronous from the script's perspective. -- **FR-012**: A malformed call — non-array argument, or an element lacking a `server` or `tool` string — MUST be rejected before any element is dispatched, with an error identifying the first offending element. -- **FR-013**: `call_tools([])` MUST return an empty array, consume no tool-call budget, and dispatch nothing. -- **FR-014**: The capability MUST be available wherever `call_tool()` is available today (every surface that executes sandbox code, in both editions), and the code-execution tool description MUST document `call_tools()` alongside `call_tool()`. +- **FR-009**: Every element MUST pass exactly the scope and permission enforcement an individual `call_tool()` performs today — caller allow-list / profile-scope restriction and permission-tier checks, with identical error codes and attribution — evaluated per element. This feature neither adds nor removes enforcement relative to `call_tool()`; any change to the sandbox's gate coverage (e.g., per-tool approval or quarantine checks) is explicitly out of scope and would apply to both primitives in a separate feature. +- **FR-010**: Every dispatched element MUST be recorded in tool-call history and activity logging exactly as an individual `call_tool()` would be, correlated to the same parent execution. Elements that fail pre-dispatch MUST produce exactly the records a lone `call_tool()` failing the same check produces today — no more, no fewer. +- **FR-011**: The sandbox MUST remain timer-free with no promises or event loop; `call_tools()` MUST be synchronous from the script's perspective; the supported JavaScript language level MUST be unchanged. +- **FR-012**: A malformed call (per the malformed-call edge case) MUST return a single `{ok: false, error}` envelope identifying the first offending element, with nothing dispatched and no budget consumed — never a thrown host exception. +- **FR-013**: Batch length MUST be capped at 100 elements; longer batches are malformed calls per FR-012. +- **FR-014**: `call_tools([])` MUST return an empty array, consume no tool-call budget, and dispatch nothing. +- **FR-015**: The capability MUST be available wherever `call_tool()` is available today (every surface that executes sandbox code, in both editions), and the code-execution tool description MUST document `call_tools()` alongside `call_tool()`. ### Key Entities - **Batch request element**: one intended upstream call — target server name, tool name, and optional arguments object. - **Batch result slot**: the outcome for one element — success envelope with the wire-shaped result, or failure envelope with an error code and message; position matches the element's input position. -- **Concurrency bound (`max_parallel`)**: the maximum number of elements in flight at once — a configured default with a per-call override inside a permitted range; subordinate to per-server limits. +- **Concurrency bound (`max_parallel`)**: the maximum number of elements in flight at once — configured default `code_execution_max_parallel` (default 8, range 1–32) with a per-call override in the same range; subordinate to per-server limits. ## Success Criteria *(mandatory)* ### Measurable Outcomes -- **SC-001**: A batch of 10 independent calls to an upstream with uniform latency completes in under 35% of the time the same 10 calls take serially (measured against a stub upstream with fixed delay). -- **SC-002**: The 31-call fan-out scenario from the originating issue (~20.6s serial) completes in under 5 seconds when expressed as batches, on the same upstreams. -- **SC-003**: In a batch with any mix of failing elements (unreachable server, unknown tool, scope violation, over-budget), 100% of slots resolve — every failure is per-slot and every sibling success is intact. -- **SC-004**: With a per-server concurrency limit configured, a batch never exceeds that limit for that server's elements (observed concurrent-request high-water mark equals the limit), regardless of `max_parallel`. +- **SC-001**: A batch of 10 independent calls to a stub upstream with a fixed 300ms latency completes in under 35% of the time the same 10 calls take as a serial `call_tool()` loop, at the default `max_parallel`. +- **SC-002**: A 31-element fan-out against a stub upstream with 500ms fixed latency (≥15.5s serial) completes in under 4 seconds at the default `max_parallel`. +- **SC-003**: In a batch with any mix of failing elements (unreachable server, unknown tool, scope violation, over-budget) that completes within the execution deadline, 100% of slots resolve — every failure is per-slot and every sibling success is intact. +- **SC-004**: With a per-server concurrency limit configured, the observed concurrent-request high-water mark for that server never exceeds the effective limit, regardless of `max_parallel` or batch size. - **SC-005**: Existing single-call scripts and all existing code-execution behavior are unchanged: the full existing test suite passes without modification (beyond additions). +## Assumptions + +- Parity means parity with `call_tool()` **as it behaves today**: the sandbox's current scope/permission checks and its current error codes and record-keeping are the reference behavior for every per-element requirement (FR-009, FR-010, US2). Known gaps in that behavior (e.g., error attribution that does not name the active profile, per-tool approval gates not applied in the sandbox) are pre-existing product decisions this feature inherits rather than resolves. +- The batch-size cap (100) and `max_parallel` ceiling (32) are fixed product constants, not configuration, chosen to bound memory and validation cost; revisiting them is a follow-up if real workloads demand it. +- A batch element's upstream call is cancelled at the execution deadline on a best-effort basis (the cancellation signal is sent; an upstream that ignores it is the upstream's defect). + ## Commit Message Conventions *(mandatory)* When committing changes for this feature, follow these guidelines: From d0246e8f75429e8c3160ed1d8b133dddf10047fd Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 14 Aug 2026 14:00:43 +0300 Subject: [PATCH 03/10] =?UTF-8?q?plan:=20096-batched-call-tools=20?= =?UTF-8?q?=E2=80=94=20research,=20plan,=20data=20model,=20contracts,=20qu?= =?UTF-8?q?ickstart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #987 Phase 0/1 artifacts. Key design decisions from code research: workers produce plain Go values with all goja conversion on the script goroutine after join; upstreamToolCaller is already concurrency-safe while ec.ToolCalls stays script-thread-only; Spec-093 admission applies automatically inside managed.Client.CallTool (queue-vs-shed corrected in the spec); execution ctx threaded via a new ExecutionContext field so workers cancel at the deadline; 7-point config wiring for code_execution_max_parallel plus two pre-existing hot-reload breaks (DetectConfigChanges clause, currentConfig() read) fixed as part of FR-004. --- CLAUDE.md | 2 +- .../contracts/call-tools-api.md | 51 ++++++++++ specs/096-batched-call-tools/data-model.md | 55 +++++++++++ specs/096-batched-call-tools/plan.md | 96 +++++++++++++++++++ specs/096-batched-call-tools/quickstart.md | 28 ++++++ specs/096-batched-call-tools/research.md | 69 +++++++++++++ specs/096-batched-call-tools/spec.md | 2 +- 7 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 specs/096-batched-call-tools/contracts/call-tools-api.md create mode 100644 specs/096-batched-call-tools/data-model.md create mode 100644 specs/096-batched-call-tools/plan.md create mode 100644 specs/096-batched-call-tools/quickstart.md create mode 100644 specs/096-batched-call-tools/research.md diff --git a/CLAUDE.md b/CLAUDE.md index e4d6b1f3..3876b9c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,6 +158,6 @@ tail -f ~/Library/Logs/mcpproxy/main.log # main log (macOS; Linux: ~/.mcpproxy/ - **Windows installer**: [docs/github-actions-windows-wix-research.md](docs/github-actions-windows-wix-research.md). **Prerelease** (`next` branch + `v*-rc.*` tags, opt-in, off stable channels): [docs/prerelease-builds.md](docs/prerelease-builds.md). ## Recent Changes +- 096-batched-call-tools: Added Go 1.24 module toolchain (repo builds with local Go 1.25) + existing only — goja (sandbox), mark3labs/mcp-go (tool surface), zap. **No new dependencies.** - 095-update-failure-ux: Added Swift 5.9 (tray, AppKit + Sparkle 2.9.3 vendored via SwiftPM) · Go 1.24 module toolchain (repo builds with local Go 1.25) + existing only — Sparkle 2.9.3 (`SPUUpdater`, `SPUStandardUserDriver`), chi (httpapi), bbolt (diagnostics counters), swaggo/swag v2 (contract regen). **No new dependencies.** - 094-filter-diagnostics: Added Go 1.24 (module toolchain; repo builds with local Go 1.25) + existing only — `mark3labs/mcp-go` (tool registration), stdlib `encoding/json`. No new dependencies. -- 091-connect-client-form: Added Swift 5.9 (SwiftUI sheet + AppKit menu) + Go 1.25 (core) + existing `internal/connect` package (registry, preview, connect, undo, backup), `internal/httpapi/connect.go` routes; Swift `APIClient` over Unix socket (admin context) diff --git a/specs/096-batched-call-tools/contracts/call-tools-api.md b/specs/096-batched-call-tools/contracts/call-tools-api.md new file mode 100644 index 00000000..f45032d5 --- /dev/null +++ b/specs/096-batched-call-tools/contracts/call-tools-api.md @@ -0,0 +1,51 @@ +# Contract: call_tools() Sandbox API (Spec 096) + +## Signature + +```js +var slots = call_tools(requests, options); +``` + +- `requests`: `Array<{server: string, tool: string, args?: object}>` — dense array, ≤100 elements. +- `options` (optional): `{max_parallel?: integer}` — 1..32; unknown keys ignored. +- Returns `Array` of slots, `slots.length === requests.length`, input order. + +## Slot envelope (identical to call_tool) + +```js +{ok: true, result: } // success +{ok: false, error: {code: string, message: string}} // failure +``` + +## Whole-call errors (single envelope, nothing dispatched, no budget) + +Returned (never thrown) when: requests is not an array; an element is not an object +with non-empty `server`/`tool` strings; supplied `args` not an object; sparse hole; +options not an object; `max_parallel` non-integer or out of 1..32; length > 100; +missing arguments. Message names the first offending element index. + +```js +{ok: false, error: {code: "INVALID_ARGS", message: "call_tools: element 3: ..."}} +``` + +## Semantics + +- Per-element enforcement/parity: same gates, codes, records as a lone `call_tool()` today. +- Pre-dispatch checks run in input order before any dispatch (budget cannot race). +- Concurrency ≤ effective max_parallel; per-server Spec-093 admission applies inside the call path (queue or shed per that server's config — never bypassed). +- Bounded by the execution timeout; workers are cancelled with the execution context; no worker mutates execution state after Execute returns. +- Empty array → `[]`, zero cost. +- Synchronous from the script's perspective; sandbox stays timer-free. + +## Config contract + +`code_execution_max_parallel` (int, default 8, range 1..32, hot-reload applies to +subsequent executions). REST `POST /api/v1/code/exec` accepts +`options.max_parallel` with the same presence-tracked pointer semantics as the +other exec options. + +## Tool description contract + +Both code_execution registrations (default surface and routing-mode builder) +document `call_tools(requests, options)` beside `call_tool` and list +`max_parallel` in the options section, with identical text. diff --git a/specs/096-batched-call-tools/data-model.md b/specs/096-batched-call-tools/data-model.md new file mode 100644 index 00000000..3c6242dc --- /dev/null +++ b/specs/096-batched-call-tools/data-model.md @@ -0,0 +1,55 @@ +# Data Model: Batched call_tools() (Spec 096) + +## Entities + +### BatchRequestElement (sandbox value, not persisted) +| Field | Type | Rules | +|-------|------|-------| +| `server` | string | required, non-empty | +| `tool` | string | required, non-empty | +| `args` | object | optional; defaults to `{}`; non-object → whole call malformed | + +Validation: element must be a plain object; sparse array holes are malformed. First offending element index is named in the error message. + +### BatchResultSlot (sandbox value, not persisted) +Exactly the `call_tool()` envelope: +- success: `{ok: true, result: }` +- failure: `{ok: false, error: {code: string, message: string}}` + +Ordering invariant: `slots[i]` corresponds to `requests[i]` for all i; `len(slots) == len(requests)` always (when the call itself is well-formed). + +Error codes reused verbatim from the lone-call path (promoted to constants during extraction): `INVALID_ARGS`, `MAX_TOOL_CALLS_EXCEEDED`, `SERVER_NOT_ALLOWED`, `ACCESS_DENIED`, `PERMISSION_DENIED`, `UPSTREAM_ERROR`, `SERIALIZATION_ERROR`. + +### BatchOptions (sandbox value) +| Field | Type | Rules | +|-------|------|-------| +| `max_parallel` | integer | optional; 1–32; fractional/non-numeric/out-of-range → whole call malformed; unknown option keys ignored | + +### Config: `code_execution_max_parallel` +| Property | Value | +|----------|-------| +| JSON key | `code_execution_max_parallel` | +| Type | integer | +| Default | 8 (absent or 0 → 8 at post-load defaulting) | +| Valid range | 1–32 (validation error outside) | +| Hot-reload | applies to executions starting after the change (read via `currentConfig()`) | +| Editions | identical in both | + +### ToolCallRecord (existing, extended usage only) +Batch-dispatched elements append the same `jsruntime.ToolCallRecord` a lone call appends, in input order, on the script goroutine after the join. Pre-dispatch failures append exactly what a lone call failing the same gate appends today (i.e., nothing — parity per FR-010). + +## Fixed product constants +| Constant | Value | Where | +|----------|-------|-------| +| Batch length cap | 100 | `internal/jsruntime` (named const) | +| max_parallel ceiling | 32 | shared by config validation + options validation | + +## State transitions (per element) + +``` +validated ──fail──▶ slot=error(INVALID_ARGS) [no budget, no dispatch] +validated ──pass──▶ gate-checked ──fail──▶ slot=error(gate code) [no budget] +gate-checked ─pass─▶ budget-checked ─fail─▶ slot=error(MAX_TOOL_CALLS_EXCEEDED) +budget-checked ─pass─▶ dispatched ──▶ normalized ──▶ slot=ok/error [budget consumed, record appended] +dispatched ──ctx cancelled──▶ (execution already failed; no observable value) +``` diff --git a/specs/096-batched-call-tools/plan.md b/specs/096-batched-call-tools/plan.md new file mode 100644 index 00000000..7ba31cd0 --- /dev/null +++ b/specs/096-batched-call-tools/plan.md @@ -0,0 +1,96 @@ +# Implementation Plan: Batched call_tools() for Parallel Upstream Calls + +**Branch**: `096-batched-call-tools` | **Date**: 2026-08-14 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/096-batched-call-tools/spec.md` + +## Summary + +Add a `call_tools(requests, options?)` host function to the code-execution JS sandbox that dispatches independent upstream calls concurrently (bounded by a new `code_execution_max_parallel` config field, per-call overridable 1–32) and returns per-slot `{ok, result}|{ok, error}` envelopes in input order. Concurrency is a bounded worker pool over the existing `ToolCaller.CallTool` path — Spec 093 per-server admission applies automatically inside `managed.Client.CallTool`. Workers produce plain Go values (dispatch + JSON-round-trip normalization); all goja conversion happens on the script goroutine after the join. Pre-dispatch checks (shape, scope, budget) run deterministically in input order before any dispatch. + +## Technical Context + +**Language/Version**: Go 1.24 module toolchain (repo builds with local Go 1.25) +**Primary Dependencies**: existing only — goja (sandbox), mark3labs/mcp-go (tool surface), zap. **No new dependencies.** +**Storage**: none new (existing BBolt tool-call history via the existing locked path) +**Testing**: `go test -race` (`internal/jsruntime`, `internal/server`, `internal/config`, `internal/runtime`), stub ToolCaller with per-call latency/concurrency high-water tracking; `./scripts/test-api-e2e.sh` +**Target Platform**: all supported (darwin/linux/windows), both editions (no server-tag code involved) +**Project Type**: single Go project, existing package layout +**Performance Goals**: SC-001/SC-002 — batch of N independent calls ≈ slowest element (10×300ms < 35% of serial; 31×500ms < 4s at default max_parallel 8) +**Constraints**: goja VM single-owner (script goroutine); `ec.ToolCalls`/`ec.maxPermissionLevel` script-thread-only; workers must honor execution ctx (FR-007); batch cap 100; sandbox stays timer-free/synchronous +**Scale/Scope**: ~5 production files touched + config wiring + 2 description strings + docs; ~10 new test files/functions + +## Constitution Check + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Performance at Scale | PASS | Feature exists to cut fan-out wall-clock; no indexing/search impact. | +| II. Actor-Based Concurrency | PASS | Bounded worker pool + channels; join before VM conversion; no new locks (workers return values; script thread owns state). Context propagation used for cancellation (FR-007) as the principle requires. | +| III. Configuration-Driven | PASS | `code_execution_max_parallel` in mcp_config.json with default+validation; hot-reload fixed as part of this feature (R6). | +| IV. Security by Default | PASS | Per-element parity enforcement (R5); Spec 093 admission preserved (R3); no new listener/surface. | +| V. TDD | PASS | Red-green per task; race-detector tests for concurrency invariants. | +| VI. Documentation Hygiene | PASS | Tool description ×2, 7 docs files, swagger regen, frontend settings field. | + +**Post-design re-check**: PASS — no violations introduced by Phase 1 design; Complexity Tracking not needed. + +## Project Structure + +### Documentation (this feature) + +```text +specs/096-batched-call-tools/ +├── plan.md # This file +├── research.md # Phase 0 (complete) +├── data-model.md # Phase 1 +├── quickstart.md # Phase 1 +├── contracts/ +│ └── call-tools-api.md # Sandbox JS API + config contract +└── tasks.md # Phase 2 (/speckit.tasks) +``` + +### Source Code (repository root) + +```text +internal/jsruntime/ +├── runtime.go # call_tools binding, worker pool, pre-dispatch pass, +│ # ExecutionContext.ctx field, gate-check extraction +├── errors.go # promote bare error-code strings to constants +├── batch_test.go # NEW: batch semantics, ordering, concurrency, cancellation +└── tool_result_test.go # existing wire-shape tests (unchanged) + +internal/server/ +├── mcp_code_execution.go # max_parallel resolution via currentConfig(); options parse +├── mcp.go # code_execution description (+call_tools, +max_parallel) +├── mcp_routing.go # duplicated description in buildCodeExecutionTool +└── code_execution_options_test.go # extend: max_parallel shapes/range + +internal/config/ +├── config.go # field, default, validation, post-load defaulting +└── config_test.go # extend + +internal/runtime/ +└── config_hotreload.go # code_execution changed-field clause (R6) + +internal/httpapi/ +└── code_exec.go # REST options passthrough for max_parallel (pointer field) + +oas/ # make swagger regen +frontend/src/views/settings/fields.ts # settings field +docs/… # 7 files per R6 +``` + +**Structure Decision**: All changes live in existing packages; the only new file is the batch test file. The batch engine is private to `internal/jsruntime` (a `runBatch` helper beside `makeCallToolsFunction`), keeping the ToolCaller interface unchanged so every existing ToolCaller implementation (server, tests) works as a batch target unmodified. + +## Design Outline (Phase 1 condensed) + +1. **Binding**: `vm.Set("call_tools", ec.makeCallToolsFunction(vm))` beside `call_tool` in `Execute` (R7). The closure runs on the script goroutine. +2. **Parse & validate (script goroutine)**: arity/array checks; per-element shape check ({server, tool, args?}); options parse (`max_parallel` integer 1–32); batch cap 100; malformed → single `{ok:false,error}` envelope (FR-012), nothing dispatched. +3. **Pre-dispatch pass (script goroutine, input order)**: for each element run the extracted scope gates (R5) and budget check; failing elements get their slot error assigned immediately and are excluded from dispatch; passing elements consume a budget reservation (append placeholder records after the join, not before — accounting below). +4. **Dispatch**: worker pool of `min(max_parallel, len(dispatchable))` goroutines consuming an index channel; each worker: `toolCaller.CallTool(ec.ctx, …)` → normalize (R1) → build the slot value and the ToolCallRecord as plain Go data; send to a results slice guarded by index ownership (each index written by exactly one worker — no lock needed). +5. **Join & convert (script goroutine)**: wait for pool completion or ctx cancellation; append records to `ec.ToolCalls` in input order (R2); `updateMaxPermissionLevel` per dispatched element; single `vm.ToValue(slots)` conversion. +6. **Cancellation (FR-007)**: workers select on `ec.ctx.Done()`; `Execute`'s existing `defer cancel()` fires on timeout return, cancelling in-flight upstream calls. The join uses the ctx too, so the script goroutine does not block past cancellation — it returns per-slot TIMEOUT-style errors for undone elements, but per the spec the execution as a whole has already failed and no value is observable. +7. **Config**: 7-point wiring + the two hot-reload fixes (R6). REST `code_exec` options gain `max_parallel *int` (presence-tracked, consistent with the PR #988 pointer-options fix). +8. **Docs & descriptions**: both description strings; 7 docs files; swagger; frontend settings entry. + +## Complexity Tracking + +Not needed — no constitution violations. diff --git a/specs/096-batched-call-tools/quickstart.md b/specs/096-batched-call-tools/quickstart.md new file mode 100644 index 00000000..bb8dc8cc --- /dev/null +++ b/specs/096-batched-call-tools/quickstart.md @@ -0,0 +1,28 @@ +# Quickstart: call_tools() batched upstream calls + +Fan out independent upstream calls in one code_execution request: + +```js +var prs = call_tools( + [1, 2, 3, 4, 5].map(function (n) { + return {server: "github", tool: "get_pull_request", + args: {owner: "acme", repo: "api", pullNumber: n}}; + }), + {max_parallel: 5} +); + +var titles = prs.map(function (r) { + if (!r.ok) { return "ERR: " + r.error.code; } + return JSON.parse(r.result.content[0].text).title; +}); +({titles: titles}) +``` + +- Results come back in input order; a failed slot never poisons its siblings. +- Each element costs one unit of `max_tool_calls` budget, checked in input order. +- Default concurrency comes from `code_execution_max_parallel` (8); override per + call with `options.max_parallel` (1..32). +- Per-server concurrency limits (Spec 093) still govern: a server capped at + `max_concurrent_requests: 1` with `queue_size: 9` serializes 10 elements; the + same server with no queue sheds the overflow as per-slot errors. +- The whole batch lives inside the execution timeout — size batches accordingly. diff --git a/specs/096-batched-call-tools/research.md b/specs/096-batched-call-tools/research.md new file mode 100644 index 00000000..2b87461f --- /dev/null +++ b/specs/096-batched-call-tools/research.md @@ -0,0 +1,69 @@ +# Research: Batched call_tools() (Spec 096) + +All findings verified against the codebase on branch `096-batched-call-tools` (which stacks on the PR #988 fixes). File:line references are to that state. + +## R1. Goja thread-safety seam + +**Decision**: Batch workers produce plain Go values only; every `vm.ToValue` happens on the script goroutine after all workers join. + +**Rationale**: `Execute` creates the VM (`internal/jsruntime/runtime.go:156`) and hands it to a single goroutine (`:186-188`); the `select` waiter (`:191-209`) never touches it. The script goroutine is the VM's sole owner, and `call_tools` is invoked from inside `vm.RunString`, i.e. on that goroutine. `normalizeToolResult` (`:421-436`) is a JSON round-trip producing only plain Go types — safe to run inside workers. So the batch does dispatch + normalize off-thread, joins, then converts the assembled results array once. + +**Alternatives considered**: locking the VM (goja explicitly unsupported); per-worker VMs (pointless — results are data, not code). + +## R2. ToolCaller concurrency + +**Decision**: `upstreamToolCaller.CallTool` may be invoked concurrently as-is; jsruntime-side execution state may only be mutated on the script goroutine. + +**Rationale**: `upstreamToolCaller` guards its `toolCalls` with `mu sync.Mutex` (`internal/server/mcp_code_execution.go:512-525`, writer `:610-622`, reader returns a copy `:625-633`); correlation ids are atomic (`internal/server/mcp.go:667-677`); history writes lock storage (serialized — acceptable, they're small). The unsafe state is `ec.ToolCalls` appends (`runtime.go:377,397,409`) and `ec.maxPermissionLevel` (`:456-460`) — unguarded, script-thread-only today. Workers therefore return completed records; the script thread appends them after the join, in input order. + +**Alternatives considered**: adding a mutex to `ExecutionContext` — more invasive, changes lone-call code paths for no benefit. + +## R3. Spec 093 per-server limits + +**Decision**: No batch-side work needed — admission happens inside `managed.Client.CallTool` (`internal/upstream/managed/client.go:726` → `acquireAdmission`, `internal/upstream/managed/admission.go:52-75`, FIFO semaphore in `internal/upstream/limiter/registry.go:344`). N concurrent workers targeting one server are bounded per config automatically. + +**Correction to the spec (folded into spec.md)**: "queues rather than bypasses" only holds when `queue_size` is configured. `Limits.QueueSize` defaults to 0 = shed immediately (`internal/upstream/limiter/limiter.go:42-44`, shed at `:262`; default resolution `internal/config/concurrency.go:37-74`). With `max_concurrent_requests: 1` and no queue, a 10-element batch yields 1 success + 9 per-slot `queue_full` errors — which is the server's configured policy, not a bypass. Tests and docs must set `queue_size` explicitly when they want queueing; docs warn that large `max_parallel` against a limited server needs queue headroom. + +## R4. Execution context threading + +**Decision**: Add an unexported `ctx context.Context` field to `ExecutionContext`, assigned from the existing `timeoutCtx` right after `runtime.go:181`; batch workers run under it. Lone `call_tool` keeps `context.Background()` verbatim (parity). + +**Rationale**: `timeoutCtx, cancel := context.WithTimeout(ctx, ...)` already exists with `defer cancel()` — when `Execute` returns (result or timeout), workers are cancelled instead of orphaning upstream calls, satisfying FR-007 without touching lone-call behavior. Known pre-existing gap, unchanged by this feature: there is no `vm.Interrupt` anywhere, so the script goroutine itself can outlive a timeout; batch workers do NOT inherit that gap because they honor ctx. + +## R5. Per-element scope checks (parity set) + +The gates in `makeCallToolFunction` (`runtime.go:273-356`), in order, with error codes: + +1. arity < 3 → `INVALID_ARGS` (`:275`) +2. args not an object → `INVALID_ARGS` (`:290`) +3. budget: `len(ec.ToolCalls) >= maxToolCalls` → `MAX_TOOL_CALLS_EXCEEDED` (`:302`) +4. allow-list/profile: → `SERVER_NOT_ALLOWED` (`:315`) +5. agent-token scope: → `ACCESS_DENIED` (`:328`) +6. permission tier via `ToolAnnotationFunc` → `PERMISSION_DENIED` (`:340-352`), side effect `updateMaxPermissionLevel` (`:355`) + +Post-dispatch: `UPSTREAM_ERROR` (`:382`), `SERIALIZATION_ERROR` (`:402`). Quarantined servers surface as unknown-server `UPSTREAM_ERROR` (they are absent from the manager, `mcp_code_execution.go:547`) — that IS the parity behavior. + +**Decision**: Extract gates 4–6 into a pure helper returning a plain error map (nil = allowed); gate side effects (budget accounting, `updateMaxPermissionLevel`) stay with the callers: lone call_tool inline as today, batch in its input-order pre-dispatch pass. Promote the bare string error codes (`INVALID_ARGS`, `ACCESS_DENIED`, `PERMISSION_DENIED`, `UPSTREAM_ERROR`) to constants in `errors.go` during extraction so batch and lone paths cannot drift. + +## R6. Config wiring for `code_execution_max_parallel` + +Seven wiring points (verified against sibling fields `CodeExecutionTimeoutMs`/`CodeExecutionMaxToolCalls`/`CodeExecutionPoolSize`): + +1. Struct field beside siblings — `internal/config/config.go:418` (`json:"code_execution_max_parallel,omitempty" mapstructure:"code-execution-max-parallel"`). +2. `DefaultConfig` — `config.go:1700-1703` (default 8). +3. Validation (range 1–32) — `config.go:2142-2158`. +4. Post-load defaulting (absent/0 → 8) — `config.go:2421-2427`. +5. Swagger/OAS regen (`make swagger`) — `oas/swagger.yaml:65-73` documents the siblings. +6. Frontend settings field — `frontend/src/views/settings/fields.ts:319-328` (`code-execution` section). +7. Docs — `docs/configuration.md`, `docs/configuration/config-file.md`, `docs/features/code-execution.md`, `docs/code_execution/{overview,api-reference,troubleshooting,cookbook}.md`. + +**No env override**: none of the four `CodeExecution*` siblings has an `MCPPROXY_*` override (`internal/config/loader.go:627-750`); staying consistent. + +**Hot-reload (two pre-existing breaks the plan must fix for FR-004)**: +- `DetectConfigChanges` (`internal/runtime/config_hotreload.go:44-272`) has no `CodeExecution*` clause — an edit touching only these fields is swallowed as "no changes". Add a `code_execution` changed-field clause covering the new field (and siblings). +- `handleCodeExecution` reads the construction-time `p.config` snapshot (`mcp_code_execution.go:63`); `p.currentConfig()` exists for live reads (`internal/server/profile_resolver.go:40-47`). Switch the defaults-resolution call site to `currentConfig()` so "applies to executions that start after the change" holds (incidentally makes the sibling fields hot-reload as their docs already imply). + +## R7. Binding site and description text + +- Host functions are bound inline in `Execute` — `input` (`runtime.go:165`), `call_tool` (`:171`); `call_tools` binds alongside with the same `vm.Set` pattern. There is no `log` global and no `setupExecutionEnvironment` function. +- The code_execution tool description is duplicated in `internal/server/mcp.go:913` (single-line string) and `internal/server/mcp_routing.go:506` (multi-line concatenation in `buildCodeExecutionTool`; disabled-stub variant at `:485-503`). Both must gain `call_tools` + `options.max_parallel` text in lockstep; the options list is at `mcp.go:934` and past `mcp_routing.go:540`. diff --git a/specs/096-batched-call-tools/spec.md b/specs/096-batched-call-tools/spec.md index dc7dfe5b..b6184f05 100644 --- a/specs/096-batched-call-tools/spec.md +++ b/specs/096-batched-call-tools/spec.md @@ -50,7 +50,7 @@ An operator caps how hard one sandbox execution may hammer upstreams. The batch 1. **Given** the configured default `max_parallel`, **When** a batch larger than the bound runs, **Then** no more than `max_parallel` elements are in flight at any moment and all elements still complete. 2. **Given** a per-call `max_parallel` override within the permitted range, **When** the batch runs, **Then** the override governs; an override outside the permitted range (or non-integer) is rejected before any element is dispatched. -3. **Given** a per-server concurrency limit lower than `max_parallel`, **When** a batch targets that server, **Then** the per-server limit governs those elements (the batch queues rather than bypasses). +3. **Given** a per-server concurrency limit lower than `max_parallel`, **When** a batch targets that server, **Then** the per-server limit governs those elements exactly as it governs individual calls: they queue when the server has queue capacity configured, and are shed with that server's standard per-call error when it does not — never bypassed. --- From 962ec21f55a2b68c5c4be0ecda3174ecb3c0f233 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 14 Aug 2026 14:03:23 +0300 Subject: [PATCH 04/10] =?UTF-8?q?plan:=20096=20review=20pass=202=20?= =?UTF-8?q?=E2=80=94=20budget-first=20check=20order,=20unconditional=20joi?= =?UTF-8?q?n,=20one-record-per-dispatch,=20prefilled=20queue,=20no=20REST?= =?UTF-8?q?=20max=5Fparallel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #987 --- .../contracts/call-tools-api.md | 4 ++-- specs/096-batched-call-tools/data-model.md | 14 +++++++++----- specs/096-batched-call-tools/plan.md | 12 +++++------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/specs/096-batched-call-tools/contracts/call-tools-api.md b/specs/096-batched-call-tools/contracts/call-tools-api.md index f45032d5..722d608b 100644 --- a/specs/096-batched-call-tools/contracts/call-tools-api.md +++ b/specs/096-batched-call-tools/contracts/call-tools-api.md @@ -41,8 +41,8 @@ missing arguments. Message names the first offending element index. `code_execution_max_parallel` (int, default 8, range 1..32, hot-reload applies to subsequent executions). REST `POST /api/v1/code/exec` accepts -`options.max_parallel` with the same presence-tracked pointer semantics as the -other exec options. +no execution-level `max_parallel`; precedence is per-batch override > +`code_execution_max_parallel` > built-in 8. ## Tool description contract diff --git a/specs/096-batched-call-tools/data-model.md b/specs/096-batched-call-tools/data-model.md index 3c6242dc..6a6be876 100644 --- a/specs/096-batched-call-tools/data-model.md +++ b/specs/096-batched-call-tools/data-model.md @@ -46,10 +46,14 @@ Batch-dispatched elements append the same `jsruntime.ToolCallRecord` a lone call ## State transitions (per element) +Check order matches lone `call_tool()` exactly: shape → budget → allow-list → agent-scope → permission-tier. + ``` -validated ──fail──▶ slot=error(INVALID_ARGS) [no budget, no dispatch] -validated ──pass──▶ gate-checked ──fail──▶ slot=error(gate code) [no budget] -gate-checked ─pass─▶ budget-checked ─fail─▶ slot=error(MAX_TOOL_CALLS_EXCEEDED) -budget-checked ─pass─▶ dispatched ──▶ normalized ──▶ slot=ok/error [budget consumed, record appended] -dispatched ──ctx cancelled──▶ (execution already failed; no observable value) +validated ──fail──▶ slot=error(INVALID_ARGS) [no budget, no dispatch] +validated ─pass─▶ budget-checked ─fail─▶ slot=error(MAX_TOOL_CALLS_EXCEEDED) [no dispatch] +budget-checked ─pass─▶ gate-checked ──fail──▶ slot=error(gate code) [no budget consumed] +gate-checked ─pass─▶ dispatched ──▶ normalized ──▶ slot=ok/error [budget consumed; exactly one record appended at join] +dispatched ──ctx cancelled──▶ slot=error + record at join [execution already failed; no observable value] ``` + +Invariant: every dispatched element produces exactly one ToolCallRecord at the unconditional join — reservations and records cannot diverge, including under cancellation. diff --git a/specs/096-batched-call-tools/plan.md b/specs/096-batched-call-tools/plan.md index 7ba31cd0..9bba63e2 100644 --- a/specs/096-batched-call-tools/plan.md +++ b/specs/096-batched-call-tools/plan.md @@ -70,8 +70,6 @@ internal/config/ internal/runtime/ └── config_hotreload.go # code_execution changed-field clause (R6) -internal/httpapi/ -└── code_exec.go # REST options passthrough for max_parallel (pointer field) oas/ # make swagger regen frontend/src/views/settings/fields.ts # settings field @@ -84,11 +82,11 @@ docs/… # 7 files per R6 1. **Binding**: `vm.Set("call_tools", ec.makeCallToolsFunction(vm))` beside `call_tool` in `Execute` (R7). The closure runs on the script goroutine. 2. **Parse & validate (script goroutine)**: arity/array checks; per-element shape check ({server, tool, args?}); options parse (`max_parallel` integer 1–32); batch cap 100; malformed → single `{ok:false,error}` envelope (FR-012), nothing dispatched. -3. **Pre-dispatch pass (script goroutine, input order)**: for each element run the extracted scope gates (R5) and budget check; failing elements get their slot error assigned immediately and are excluded from dispatch; passing elements consume a budget reservation (append placeholder records after the join, not before — accounting below). -4. **Dispatch**: worker pool of `min(max_parallel, len(dispatchable))` goroutines consuming an index channel; each worker: `toolCaller.CallTool(ec.ctx, …)` → normalize (R1) → build the slot value and the ToolCallRecord as plain Go data; send to a results slice guarded by index ownership (each index written by exactly one worker — no lock needed). -5. **Join & convert (script goroutine)**: wait for pool completion or ctx cancellation; append records to `ec.ToolCalls` in input order (R2); `updateMaxPermissionLevel` per dispatched element; single `vm.ToValue(slots)` conversion. -6. **Cancellation (FR-007)**: workers select on `ec.ctx.Done()`; `Execute`'s existing `defer cancel()` fires on timeout return, cancelling in-flight upstream calls. The join uses the ctx too, so the script goroutine does not block past cancellation — it returns per-slot TIMEOUT-style errors for undone elements, but per the spec the execution as a whole has already failed and no value is observable. -7. **Config**: 7-point wiring + the two hot-reload fixes (R6). REST `code_exec` options gain `max_parallel *int` (presence-tracked, consistent with the PR #988 pointer-options fix). +3. **Pre-dispatch pass (script goroutine, input order, per-element check order IDENTICAL to lone call_tool today — R5)**: for each element: shape → **budget → allow-list → agent-scope → permission-tier** (budget first, exactly as `runtime.go:301-315` orders it, so an exhausted-budget + disallowed element errors with `MAX_TOOL_CALLS_EXCEEDED` on both primitives). Failing elements get their slot error immediately and are excluded from dispatch. The budget check for element k uses `len(ec.ToolCalls) + dispatchedSoFar(k)` — a script-thread-local counter of elements already accepted for dispatch in this batch; no cross-goroutine accounting exists, so it cannot race. Every accepted element is GUARANTEED exactly one ToolCallRecord at the join (success, upstream error, serialization error, or cancellation error), so reservations and records can never diverge. +4. **Dispatch**: prefill a buffered channel with all dispatchable indices, close it, then start `min(max_parallel, len(dispatchable))` workers — no producer goroutine exists, so nothing can deadlock on send. Each worker drains the closed channel (checking `ec.ctx.Err()` between takes; a cancelled worker records the remaining takes as cancellation-error slots rather than dispatching), and per index: `toolCaller.CallTool(ec.ctx, …)` → normalize (R1) → write the slot value and the ToolCallRecord as plain Go data into its index-owned cell (each cell written by exactly one worker — no lock). +5. **Join & convert (script goroutine)**: the join is an UNCONDITIONAL WaitGroup wait — the script goroutine never proceeds (and never touches slots) while any worker is alive; cancellation accelerates worker completion (in-flight `CallTool` calls return promptly on ctx error) but never bypasses the join. After the join: append the per-element records to `ec.ToolCalls` in input order (R2), `updateMaxPermissionLevel` per dispatched element, single `vm.ToValue(slots)` conversion. +6. **Cancellation (FR-007)**: `Execute`'s existing `defer cancel()` fires when it returns on the timeout branch, cancelling in-flight upstream calls; workers finish their cells and exit; the join completes; the script goroutine then continues into state that is already orphaned — exactly the lone-call post-timeout behavior today (parity). Workers themselves never mutate execution state (`ec.*`); their only shared writes are index-owned cells (pre-join) and `upstreamToolCaller`'s internally-locked records. Records completing after `handleCodeExecution` has read `getToolCalls()` are dropped from that response, identical to a lone call in flight at timeout today. +7. **Config**: 7-point wiring + the two hot-reload fixes (R6). No REST/MCP execution-level `max_parallel` option — the spec mandates only the config default and the per-batch `call_tools(..., {max_parallel})` override; precedence is exactly `per-batch override > code_execution_max_parallel > built-in 8`. 8. **Docs & descriptions**: both description strings; 7 docs files; swagger; frontend settings entry. ## Complexity Tracking From 3c32eb6254974d68b92ad0e237039138e7ec1a4c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 14 Aug 2026 14:05:10 +0300 Subject: [PATCH 05/10] =?UTF-8?q?spec+plan:=20096=20=E2=80=94=20FR-007=20a?= =?UTF-8?q?ligned=20to=20enforceable=20lone-call=20timeout=20parity;=20dro?= =?UTF-8?q?p=20stale=20execution-level=20max=5Fparallel=20plan=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #987 --- specs/096-batched-call-tools/contracts/call-tools-api.md | 2 +- specs/096-batched-call-tools/plan.md | 4 ++-- specs/096-batched-call-tools/spec.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/specs/096-batched-call-tools/contracts/call-tools-api.md b/specs/096-batched-call-tools/contracts/call-tools-api.md index 722d608b..ae8861fa 100644 --- a/specs/096-batched-call-tools/contracts/call-tools-api.md +++ b/specs/096-batched-call-tools/contracts/call-tools-api.md @@ -33,7 +33,7 @@ missing arguments. Message names the first offending element index. - Per-element enforcement/parity: same gates, codes, records as a lone `call_tool()` today. - Pre-dispatch checks run in input order before any dispatch (budget cannot race). - Concurrency ≤ effective max_parallel; per-server Spec-093 admission applies inside the call path (queue or shed per that server's config — never bypassed). -- Bounded by the execution timeout; workers are cancelled with the execution context; no worker mutates execution state after Execute returns. +- Bounded by the execution timeout; workers are cancelled with the execution context and never mutate script-visible execution state; an in-flight element completing after the execution returns lands only in internally-synchronized records excluded from that execution's response (lone-call timeout parity). - Empty array → `[]`, zero cost. - Synchronous from the script's perspective; sandbox stays timer-free. diff --git a/specs/096-batched-call-tools/plan.md b/specs/096-batched-call-tools/plan.md index 9bba63e2..8757597c 100644 --- a/specs/096-batched-call-tools/plan.md +++ b/specs/096-batched-call-tools/plan.md @@ -58,10 +58,10 @@ internal/jsruntime/ └── tool_result_test.go # existing wire-shape tests (unchanged) internal/server/ -├── mcp_code_execution.go # max_parallel resolution via currentConfig(); options parse +├── mcp_code_execution.go # resolve config default into ExecutionOptions.MaxParallel via currentConfig() (no request-level option) ├── mcp.go # code_execution description (+call_tools, +max_parallel) ├── mcp_routing.go # duplicated description in buildCodeExecutionTool -└── code_execution_options_test.go # extend: max_parallel shapes/range +└── code_execution_options_test.go # extend: default resolution for MaxParallel (config/absent/zero) internal/config/ ├── config.go # field, default, validation, post-load defaulting diff --git a/specs/096-batched-call-tools/spec.md b/specs/096-batched-call-tools/spec.md index b6184f05..a06bbfa1 100644 --- a/specs/096-batched-call-tools/spec.md +++ b/specs/096-batched-call-tools/spec.md @@ -75,7 +75,7 @@ An operator caps how hard one sandbox execution may hammer upstreams. The batch - **FR-004**: Concurrency MUST be bounded by `max_parallel`, defined as: a new configuration field `code_execution_max_parallel` (integer; default 8 when absent or 0; permitted range 1–32; identical in both editions; a changed value applies to executions that start after the change), overridable per call via `options.max_parallel` (integer; same 1–32 range; fractional, non-numeric, or out-of-range values reject the whole call as malformed before any dispatch). - **FR-005**: Batched elements MUST respect the same per-server concurrency and queueing limits that govern individual upstream calls (Spec 093); the batch MUST NOT provide any path around them. - **FR-006**: Each dispatched element MUST count against the execution's `max_tool_calls` budget individually, with budget evaluated deterministically in input order during pre-dispatch checks (before any element is dispatched). Elements beyond the remaining budget MUST resolve per-slot with the same over-budget error as a lone `call_tool()`, without being dispatched. Elements rejected pre-dispatch (scope, shape, budget) MUST NOT consume budget — matching what a lone `call_tool()` consumes today for the same failure. -- **FR-007**: The batch MUST be bounded by the overall execution timeout with no additional deadline of its own. Batch workers MUST run under the execution's lifetime: at the execution deadline, queued elements are not dispatched, in-flight upstream calls are cancelled, and no batch worker may mutate execution state (records, logs, results) after the execution has returned. +- **FR-007**: The batch MUST be bounded by the overall execution timeout with no additional deadline of its own. Batch workers MUST run under the execution's lifetime: at the execution deadline, queued elements are not dispatched and in-flight upstream calls are cancelled. Workers MUST never mutate script-visible execution state; completion of an already-in-flight element's internal record after the execution has returned is permitted only through internally-synchronized paths and is excluded from that execution's reported results — exactly the behavior a lone `call_tool()` in flight at timeout exhibits today. - **FR-008**: Each slot's `result` MUST be presented in the same documented wire (JSON) shape as `call_tool()` results — never as live host values. - **FR-009**: Every element MUST pass exactly the scope and permission enforcement an individual `call_tool()` performs today — caller allow-list / profile-scope restriction and permission-tier checks, with identical error codes and attribution — evaluated per element. This feature neither adds nor removes enforcement relative to `call_tool()`; any change to the sandbox's gate coverage (e.g., per-tool approval or quarantine checks) is explicitly out of scope and would apply to both primitives in a separate feature. - **FR-010**: Every dispatched element MUST be recorded in tool-call history and activity logging exactly as an individual `call_tool()` would be, correlated to the same parent execution. Elements that fail pre-dispatch MUST produce exactly the records a lone `call_tool()` failing the same check produces today — no more, no fewer. From 542ee97d39b2875b0d5384355717ff873fa0fc93 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 14 Aug 2026 14:07:17 +0300 Subject: [PATCH 06/10] =?UTF-8?q?tasks:=20096-batched-call-tools=20?= =?UTF-8?q?=E2=80=94=2016=20tasks=20across=20foundational=20+=203=20user?= =?UTF-8?q?=20stories=20+=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #987 --- ROADMAP.md | 1 + specs/096-batched-call-tools/tasks.md | 67 +++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 specs/096-batched-call-tools/tasks.md diff --git a/ROADMAP.md b/ROADMAP.md index eb18896d..bf53ff0f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -797,3 +797,4 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [093-concurrency-limits](./specs/093-concurrency-limits/) | — | — | | [094-filter-diagnostics](./specs/094-filter-diagnostics/) | `shipped` | 14/14 (100%) | | [095-update-failure-ux](./specs/095-update-failure-ux/) | `shipped` | 28/28 (100%) | +| [096-batched-call-tools](./specs/096-batched-call-tools/) | `drafted` | 0/16 (0%) | diff --git a/specs/096-batched-call-tools/tasks.md b/specs/096-batched-call-tools/tasks.md new file mode 100644 index 00000000..29e9ebca --- /dev/null +++ b/specs/096-batched-call-tools/tasks.md @@ -0,0 +1,67 @@ +# Tasks: Batched call_tools() for Parallel Upstream Calls + +**Input**: Design documents from `/specs/096-batched-call-tools/` +**Prerequisites**: plan.md, research.md, data-model.md, contracts/call-tools-api.md, quickstart.md +**Convention**: TDD per constitution — every implementation task's tests are written first and observed failing. + +## Phase 1: Setup + +No setup tasks — existing Go project, no new dependencies. + +## Phase 2: Foundational (blocking all user stories) + +- [ ] T001 Promote the bare sandbox error-code string literals (`INVALID_ARGS`, `ACCESS_DENIED`, `PERMISSION_DENIED`, `UPSTREAM_ERROR`) to constants in internal/jsruntime/errors.go and use them in internal/jsruntime/runtime.go makeCallToolFunction; existing tests must stay green (pure refactor, byte-identical error payloads). +- [ ] T002 Extract the per-element gates (allow-list, agent-scope, permission-tier — R5 gates 4–6) from makeCallToolFunction into a pure helper on ExecutionContext returning a plain error map (nil = allowed) in internal/jsruntime/runtime.go, side effects (budget read, updateMaxPermissionLevel) staying at call sites; add a parity test in internal/jsruntime/batch_test.go asserting the helper reproduces the lone-call codes and check order (budget-first overall, per data-model.md). +- [ ] T003 Add unexported `ctx context.Context` to ExecutionContext, assigned from the existing timeoutCtx right after its creation in Execute (internal/jsruntime/runtime.go:~181); unit test in internal/jsruntime/batch_test.go asserts the field is non-nil during execution and cancelled once Execute returns; lone call_tool continues to use context.Background() (assert unchanged behavior via existing tests). +- [ ] T004 [P] Add `code_execution_max_parallel` config field: struct field beside siblings (internal/config/config.go:~418), DefaultConfig 8 (~1700), range validation 1–32 (~2142), post-load defaulting absent/0→8 (~2421); table-driven tests in internal/config/config_test.go (default, explicit, invalid range, zero) written first and observed failing. +- [ ] T005 Fix the two hot-reload breaks (R6): add a code_execution changed-field clause to DetectConfigChanges in internal/runtime/config_hotreload.go covering all four CodeExecution* fields; switch the resolveCodeExecutionDefaults call site in internal/server/mcp_code_execution.go to read via p.currentConfig(); add `MaxParallel int` to jsruntime.ExecutionOptions and extend resolveCodeExecutionDefaults (unset/0 → config value); failing-first tests in internal/runtime/config_hotreload_test.go (edit touching only code_execution_max_parallel produces a change event) and internal/server/code_execution_options_test.go (MaxParallel default resolution). + +**Checkpoint**: `go test -race ./internal/jsruntime/... ./internal/config/... ./internal/runtime/... ./internal/server/ -run 'CodeExec|Config'` green; no behavior change observable to existing callers. + +## Phase 3: User Story 1 — Fan-out completes in parallel time (P1) + +**Goal**: `call_tools()` exists, runs elements concurrently, returns ordered per-slot envelopes. +**Independent test**: stub ToolCaller with fixed 50ms latency; batch of 10 completes < 35% of serial time with identical results. + +- [ ] T006 [US1] Write failing batch-core tests in internal/jsruntime/batch_test.go using a latency/concurrency-recording stub ToolCaller: (a) 10-element batch returns 10 slots in input order with lone-call-identical envelopes; (b) wall-clock < 35% of the serial equivalent (SC-001 shape); (c) `call_tools([])` returns `[]` with zero budget consumed; (d) single-element batch byte-equivalent to lone call_tool for the same request; (e) results are plain JSON wire shapes (reuse tool_result_test.go assertions). +- [ ] T007 [US1] Implement makeCallToolsFunction + private runBatch in internal/jsruntime/runtime.go per plan Design Outline steps 2–5: script-goroutine parse/validate (arity, dense array, element shape, options integer 1–32, cap 100 → single INVALID_ARGS envelope naming first offending index); input-order pre-dispatch pass (budget-first check order via T002 helper, script-thread-local dispatch counter); prefilled-closed-channel worker pool of min(max_parallel, dispatchable) honoring ec.ctx; workers produce plain slot values + ToolCallRecords into index-owned cells; unconditional WaitGroup join; input-order record append + updateMaxPermissionLevel + single vm.ToValue. Make T006 green. +- [ ] T008 [US1] Bind `call_tools` in Execute beside call_tool (internal/jsruntime/runtime.go:~171) and add it (+ `options.max_parallel`) to BOTH code_execution description strings — internal/server/mcp.go:~913/934 and internal/server/mcp_routing.go buildCodeExecutionTool:~506/540 — with identical wording; test in internal/server/code_execution_options_test.go (or a new surfaces test) asserting both descriptions mention call_tools, written first. + +**Checkpoint**: quickstart.md example works against a stub; US1 acceptance scenarios pass. + +## Phase 4: User Story 2 — One failure never poisons the batch (P2) + +**Goal**: per-slot error isolation and whole-call validation semantics pinned. +**Independent test**: mixed-failure batch resolves 100% of slots. + +- [ ] T009 [US2] Write failing error-isolation tests in internal/jsruntime/batch_test.go: (a) batch of 5 with element 3 hitting an upstream error → 4 ok slots + 1 UPSTREAM_ERROR slot, order intact; (b) scope-violating element → SERVER_NOT_ALLOWED/ACCESS_DENIED slot per lone-call parity, siblings unaffected; (c) over-budget tail elements → MAX_TOOL_CALLS_EXCEEDED slots, not dispatched (stub records dispatch count); (d) non-serializable result → SERIALIZATION_ERROR slot; (e) malformed calls (non-array, bad element shape, non-object args, sparse hole, options non-object, fractional/out-of-range max_parallel, >100 elements) → single INVALID_ARGS envelope naming the first offending index, stub records zero dispatches; (f) args omitted defaults to {}. +- [ ] T010 [US2] Fix any behavior T009 exposes in internal/jsruntime/runtime.go until green; no test may be weakened to pass. + +**Checkpoint**: US2 acceptance scenarios + SC-003 pass. + +## Phase 5: User Story 3 — Concurrency stays operator-controlled (P3) + +**Goal**: max_parallel bound + override, cancellation discipline, Spec-093 subordination. +**Independent test**: concurrency high-water mark obeys the bound; timeout cancels workers with a full join. + +- [ ] T011 [US3] Write failing concurrency-bound tests in internal/jsruntime/batch_test.go: (a) batch of 10 with max_parallel 3 → stub high-water mark ≤ 3, all complete; (b) per-call override beats ExecutionOptions.MaxParallel; (c) ExecutionOptions.MaxParallel (config default path) governs when no override; (d) built-in 8 when neither set. +- [ ] T012 [US3] Write failing cancellation tests in internal/jsruntime/batch_test.go and make them green: execution timeout mid-batch (stub blocks until ctx cancel) → Execute returns TIMEOUT; stub asserts every in-flight ctx was cancelled; instrument runBatch (test hook or stub-side sync) to prove the join completed and exactly one ToolCallRecord exists per dispatched element; whole file must pass under -race. +- [ ] T013 [US3] Concurrent-safety test at the server seam in internal/server/mcp_code_execution_test.go: N goroutines invoking upstreamToolCaller.CallTool concurrently (as batch workers will) → getToolCalls returns N records, -race clean; reference (not re-test) Spec 093's admission coverage in the test comment, and assert the ctx passed by workers reaches the ToolCaller (stub captures it). + +**Checkpoint**: US3 acceptance scenarios + SC-004 shape pass; `go test -race ./internal/jsruntime/...` green. + +## Phase 6: Polish & Cross-Cutting + +- [ ] T014 [P] Regenerate OpenAPI (`make swagger`) for the new config field and add `code_execution_max_parallel` (number, min 1, max 32) to the code-execution section of frontend/src/views/settings/fields.ts; frontend unit test only if an existing pattern covers sibling fields (tests live in frontend/tests/unit/*.spec.ts). +- [ ] T015 [P] Documentation: add call_tools() + max_parallel to docs/configuration.md, docs/configuration/config-file.md, docs/features/code-execution.md, docs/code_execution/{overview,api-reference,troubleshooting,cookbook}.md, including the queue-vs-shed note from research R3 (queue_size headroom warning). +- [ ] T016 Full verification: `go build ./cmd/mcpproxy` + `-tags server`; `go test -race -count=1 ./internal/...`; `go test -tags server ./internal/serveredition/... -race`; `/opt/homebrew/bin/golangci-lint run --config .github/.golangci.yml ./...`; `./scripts/test-api-e2e.sh`; revert e2e-config churn; run the quickstart example end-to-end via REST code/exec against the e2e everything-server as a smoke check. + +## Dependencies + +- Phase 2 (T001–T005) blocks everything; within it T001→T002 sequential, T003 independent, T004→T005 (T005 consumes the field), T004 parallel with T001–T003. +- US1 (T006–T008) → US2 (T009–T010) → US3 (T011–T013) is the natural order; US2/US3 tests are additive over the same engine, so stories stay independently verifiable but share T007's implementation. +- Polish (T014–T016) after all stories; T014/T015 parallel. + +## Implementation Strategy + +MVP = Phase 2 + US1 (a working, bounded, ordered batch). US2/US3 pin semantics the engine already carries; polish ships config surface + docs. Suggested execution: single implementation agent for Phases 2–5 (the engine is one coherent seam in runtime.go; splitting it across agents invites merge conflicts in one function), parallel agents for T014/T015, orchestrator runs T016. From 300f9eea1dd02a8ca57f9eae858f98d983fb0d23 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 14 Aug 2026 15:01:28 +0300 Subject: [PATCH 07/10] feat: batched call_tools() for parallel upstream calls in the code-execution sandbox Related #987 call_tools(requests, options?) dispatches independent upstream calls concurrently (bounded worker pool, prefilled closed channel) and returns per-slot {ok,result}|{ok,error} envelopes in input order. Workers produce plain Go values; all goja conversion happens on the script goroutine after an unconditional join. Per-element checks run in input order with lone-call parity (budget -> allow-list -> agent-scope -> permission-tier); every dispatched element yields exactly one ToolCallRecord, including under cancellation. Spec-093 per-server admission applies unchanged inside the call path. ## Changes - internal/jsruntime: call_tools binding, batch engine, ExecutionContext ctx threading (workers cancel with the execution), error-code constants, gate extraction; extensive batch_test.go incl. -race cancellation tests - internal/config: code_execution_max_parallel (default 8, range 1-32) - internal/runtime: DetectConfigChanges clause for CodeExecution* fields (pre-existing hot-reload break) - internal/server: defaults resolved via currentConfig() (second pre-existing hot-reload break), MaxParallel resolution, deduplicated code_execution descriptions into shared constants documenting call_tools - oas/frontend/docs: swagger regen, settings field, 7 docs files ## Testing - TDD throughout (red observed first; two invariants mutation-tested) - go test -race ./internal/... green; server-edition green; lint v2 0 issues; e2e 65/65 --- ROADMAP.md | 2 +- docs/code_execution/api-reference.md | 106 +- docs/code_execution/cookbook.md | 104 +- docs/code_execution/overview.md | 55 +- docs/code_execution/troubleshooting.md | 118 ++ docs/configuration.md | 27 +- docs/configuration/config-file.md | 26 + docs/features/code-execution.md | 33 +- frontend/src/views/settings/fields.ts | 1 + ...ttings-code-execution-max-parallel.spec.ts | 39 + internal/config/config.go | 12 + internal/config/config_test.go | 71 ++ internal/jsruntime/batch_test.go | 1000 +++++++++++++++++ internal/jsruntime/errors.go | 14 + internal/jsruntime/runtime.go | 545 +++++++-- internal/runtime/config_hotreload.go | 14 + internal/runtime/config_hotreload_test.go | 42 + .../server/code_execution_concurrency_test.go | 52 + .../server/code_execution_options_test.go | 64 +- internal/server/mcp.go | 10 +- internal/server/mcp_code_execution.go | 64 +- internal/server/mcp_routing.go | 31 +- oas/docs.go | 2 +- oas/swagger.yaml | 4 + specs/096-batched-call-tools/tasks.md | 30 +- 25 files changed, 2283 insertions(+), 183 deletions(-) create mode 100644 frontend/tests/unit/settings-code-execution-max-parallel.spec.ts create mode 100644 internal/jsruntime/batch_test.go create mode 100644 internal/server/code_execution_concurrency_test.go diff --git a/ROADMAP.md b/ROADMAP.md index bf53ff0f..e8920e1a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -797,4 +797,4 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [093-concurrency-limits](./specs/093-concurrency-limits/) | — | — | | [094-filter-diagnostics](./specs/094-filter-diagnostics/) | `shipped` | 14/14 (100%) | | [095-update-failure-ux](./specs/095-update-failure-ux/) | `shipped` | 28/28 (100%) | -| [096-batched-call-tools](./specs/096-batched-call-tools/) | `drafted` | 0/16 (0%) | +| [096-batched-call-tools](./specs/096-batched-call-tools/) | `in-flight` | 15/16 (94%) | diff --git a/docs/code_execution/api-reference.md b/docs/code_execution/api-reference.md index 2004d24e..bb4f755e 100644 --- a/docs/code_execution/api-reference.md +++ b/docs/code_execution/api-reference.md @@ -275,6 +275,80 @@ if (!res.ok) { var data = res.result; ``` +#### `call_tools(requests, options)` + +Calls **independent** upstream MCP tools in parallel and returns one result slot +per request, in input order. + +**Parameters**: +- `requests` (array, required): Up to 100 elements of `{server, tool, args}`. + `server` and `tool` are non-empty strings; `args` is optional and defaults to `{}`. +- `options` (object, optional): `{max_parallel}` — integer 1-32, defaults to the + configured `code_execution_max_parallel` (8). Unknown keys are ignored. + +**Returns**: An array with `slots.length === requests.length`, where each slot is +the same envelope `call_tool()` returns: + +```javascript +// slots[i] for a successful requests[i] +{ + "ok": true, + "result": +} + +// slots[i] for a failed requests[i] +{ + "ok": false, + "error": { + "message": "", + "code": "" + } +} +``` + +**Example**: +```javascript +var slots = call_tools( + [1, 2, 3, 4, 5].map(function (n) { + return {server: 'github', tool: 'get_pull_request', + args: {owner: 'acme', repo: 'api', pullNumber: n}}; + }), + {max_parallel: 5} +); + +var titles = slots.map(function (r) { + if (!r.ok) { return 'ERR: ' + r.error.code; } + return JSON.parse(r.result.content[0].text).title; +}); +({titles: titles}); +``` + +**Semantics**: +- Per-element enforcement matches a lone `call_tool()`: the same gates, the same + error codes, the same activity records. One failing element never affects its + siblings. +- Each element costs one unit of `max_tool_calls`, checked in input order before + anything is dispatched. +- Concurrency never exceeds the effective `max_parallel`, and per-server + concurrency limits still apply inside the call path. +- The whole batch runs inside the execution timeout; a timeout cancels in-flight + elements. +- `call_tools([])` returns `[]` and costs nothing. Like `call_tool()`, the + function is **synchronous** — do not use `await`. + +**Whole-call errors**: a malformed call returns a **single** envelope (not an +array) and dispatches nothing: + +```javascript +{ok: false, error: {code: "INVALID_ARGS", message: "call_tools: element 3: ..."}} +``` + +This happens when `requests` is not an array, an element is not an object with +non-empty `server`/`tool` strings, a supplied `args` is not an object, the array +has a sparse hole, `options` is not an object, `max_parallel` is not an integer +in 1-32, or the batch exceeds 100 elements. The message names the first +offending element index. + ### Available JavaScript Features #### JavaScript Standard Library (ES2020+) @@ -330,6 +404,7 @@ var isObject = typeof value === 'object' && value !== null; | `MAX_TOOL_CALLS_EXCEEDED` | Tool call limit exceeded | Code called `call_tool()` more than `max_tool_calls` times | Reduce tool calls, increase limit, or use pagination | | `SERVER_NOT_ALLOWED` | Server not in allowed list | Attempted to call server not in `allowed_servers` | Add server to allowed list or remove restriction | | `SERIALIZATION_ERROR` | Result not JSON-serializable | Return value contains functions, circular refs, etc. | Return only plain objects, arrays, primitives | +| `INVALID_ARGS` | Host function called with arguments it cannot interpret | Wrong arity for `call_tool()`, or a malformed `call_tools()` batch (bad element shape, bad `max_parallel`, >100 elements) | Fix the offending argument — the message names the first offending element index | ### Error Examples @@ -463,7 +538,8 @@ Edit `~/.mcpproxy/mcp_config.json`: "enable_code_execution": false, "code_execution_timeout_ms": 120000, "code_execution_max_tool_calls": 0, - "code_execution_pool_size": 10 + "code_execution_pool_size": 10, + "code_execution_max_parallel": 8 } ``` @@ -475,6 +551,7 @@ Edit `~/.mcpproxy/mcp_config.json`: | `code_execution_timeout_ms` | number | `120000` | Default timeout in milliseconds (range: 1-600000) | | `code_execution_max_tool_calls` | number | `0` | Default max tool calls (0 = unlimited) | | `code_execution_pool_size` | number | `10` | Number of JavaScript VM instances in pool (range: 1-100) | +| `code_execution_max_parallel` | number | `8` | Default concurrency for `call_tools()` batches (range: 1-32). Hot-reloaded; applies to executions started after the change | ### Per-Request Overrides @@ -493,6 +570,10 @@ Per-request options override global configuration: **Priority**: Request options > Global config > Built-in defaults +`max_parallel` is deliberately **not** a request option: batch concurrency is +overridden inside the script, per batch, with `call_tools(requests, {max_parallel})`. +Its priority is per-batch override > `code_execution_max_parallel` > built-in 8. + --- ## CLI Reference @@ -628,6 +709,15 @@ mcpproxy code exec --file=/tmp/script.js - **max_tool_calls**: Must be >= 0 - **allowed_servers**: Must be array of strings (server names) +### `call_tools()` Batch Validation + +- **requests**: Must be a dense array of at most 100 elements +- **element**: Must be an object with non-empty `server` and `tool` strings; a + supplied `args` must be an object (omitted = `{}`) +- **options.max_parallel**: Must be an integer between 1 and 32 +- A violation returns one `INVALID_ARGS` envelope naming the first offending + index; no element is dispatched and no budget is consumed + ### Return Value Validation **Valid return values**: @@ -655,6 +745,20 @@ The pool size determines how many concurrent executions can run simultaneously: **Recommendation**: Start with default (10) and adjust based on load. +### Batch Concurrency (`call_tools`) + +`code_execution_max_parallel` (default 8) bounds how many elements of one batch +run at once; `call_tools(requests, {max_parallel})` overrides it per batch +(1-32). A batch of N independent calls costs roughly `ceil(N / max_parallel) × +slowest-call` instead of the sum of all calls. + +**Interaction with per-server limits**: Spec 093 concurrency limits are enforced +inside the call path and are never bypassed by batching. A server with +`max_concurrent_requests: 1` and `queue_size: 9` serializes a 10-element batch; +the same server with **no** `queue_size` sheds the overflow, returning 1 result +and 9 per-slot `queue_full` errors. Give limited servers `queue_size` headroom — +or lower `max_parallel` to match their cap — before fanning out against them. + ### Timeout Settings | Use Case | Recommended Timeout | diff --git a/docs/code_execution/cookbook.md b/docs/code_execution/cookbook.md index f5f8c1e9..f26d142f 100644 --- a/docs/code_execution/cookbook.md +++ b/docs/code_execution/cookbook.md @@ -19,7 +19,9 @@ annotations and omit `language`. > **New to code execution?** Read [overview.md](overview.md) first, then the > [api-reference.md](api-reference.md) for the full tool schema. This cookbook > assumes you know that `call_tool(server, tool, args)` returns -> `{ ok: true, result }` or `{ ok: false, error }`, and that the script's +> `{ ok: true, result }` or `{ ok: false, error }`, that +> `call_tools(requests, options)` returns one such envelope per request in input +> order, and that the script's > **last expression** becomes the result `value`. A bare top‑level `return` > is a **SyntaxError** (`Illegal return statement`) — `return` is only legal > inside a function. To early‑exit, wrap the body in an IIFE (see the @@ -64,22 +66,35 @@ the #1 source of surprises: | Capability | Status | Implication for recipes | |------------|--------|-------------------------| -| `call_tool(server, tool, args)` | ✅ | The only way to reach upstream tools. Synchronous — returns when the tool responds. | +| `call_tool(server, tool, args)` | ✅ | Reaches one upstream tool. Synchronous — returns when the tool responds. | +| `call_tools(requests, options)` | ✅ | Reaches **independent** upstream tools in parallel: an array of ≤100 `{server, tool, args}` objects in, one `{ok, result}` / `{ok, error}` slot per request out, in input order. Also synchronous. | | `input` global | ✅ | Your parameters. Type it with `as` or an `interface` for IDE‑grade safety. | | ES2020+ stdlib (`map`/`filter`/`reduce`, `JSON`, `Math`, `Date`) | ✅ | Use it freely for transforms and aggregation. | | `console.log` | ✅ | Goes to **server logs**, not the result. Use for debugging. | | Top‑level `return` | ❌ | `return` outside a function is a **SyntaxError** (`Illegal return statement`). The result is the script's **last expression**. To early‑exit, wrap the body in an IIFE: `(() => { … return x; })()`. | | `setTimeout` / `setInterval` | ❌ | **No wall‑clock sleep.** "Backoff" and "rate‑limit" recipes work by *bounding* and *chunking*, never by sleeping. | | `require` / `import` / `fetch` / `fs` | ❌ | No modules, no network, no filesystem. All I/O goes through `call_tool`. | -| Concurrency | ❌ (sequential) | Tool calls run **one at a time** server‑side. "Fan‑out" saves *round‑trips*, not wall‑clock from parallelism. Be honest about this when estimating latency. | +| Concurrency | ✅ only via `call_tools` | Loops of `call_tool` run **one at a time**; a `call_tools` batch runs up to `max_parallel` elements at once (default `code_execution_max_parallel`, 8). Sequential loops save *round‑trips*; batches also save wall‑clock. | -Two control knobs you will reach for constantly (set in `options`): +Control knobs you will reach for constantly (set in `options`): - `max_tool_calls` — a hard ceiling that aborts the script with `MAX_TOOL_CALLS_EXCEEDED`. Always set it on loops so a bad `input` can't fan - out unbounded. + out unbounded. Each `call_tools` element counts as one call. - `timeout_ms` — wall‑clock budget (default 120 000, max 600 000). The - transpile step counts toward it (negligibly). + transpile step counts toward it (negligibly), and a whole `call_tools` batch + lives inside it. + +Batch concurrency is **not** an `options` field: it comes from the +`code_execution_max_parallel` config key (default 8) and is overridden per batch +with `call_tools(requests, {max_parallel})` (1–32). + +> **Check the target server's limits before you fan out.** Per‑server +> [concurrency limits](../configuration.md#concurrency-limits--request-queueing) +> still apply and are never bypassed: a server with `max_concurrent_requests: 1` +> and `queue_size: 9` serializes a 10‑element batch, while the same server with +> **no** `queue_size` sheds the overflow as nine per‑slot `queue_full` errors. +> Set `queue_size` headroom, or match `max_parallel` to the cap. --- @@ -110,6 +125,34 @@ read the previous result before issuing the next. **Guardrail:** set `options.max_tool_calls` to `usernames.length` (or a sane cap) so an oversized input can't run away. +**Parallel variant:** the lookups are independent, so `call_tools` turns the +sequential loop into one bounded fan‑out — the batch takes about as long as its +slowest element instead of the sum: + +```typescript +// language: "typescript" +// input: { "usernames": ["octocat", "torvalds", "gaearon"] } +const slots = call_tools( + (input.usernames as string[]).map((login: string) => ({ + server: "github", tool: "get_user", args: { username: login }, + })), + { max_parallel: 5 }, +); + +const users = slots.map((res: any, i: number) => { + const login = (input.usernames as string[])[i]; + if (!res.ok) return { login, error: res.error.message }; + const u = res.result as User; + return { login, name: u.name, followers: u.followers }; +}); + +({ users, count: users.length }); +``` + +Slots come back in input order (`slots[i]` ↔ `requests[i]`), one failing element +never poisons the rest, and a batch is capped at 100 elements — chunk longer +lists into several `call_tools` calls. + --- ## Recipe 2 — Fan‑out + merge (many tools, one object) @@ -136,8 +179,30 @@ const ci = call_tool("ci", "latest_pipeline", { project: input.repo }); **Replaces:** 3 round‑trips + a final model turn to stitch the pieces together. Here the merge happens server‑side; the model sees one tidy object. -**Note on "parallel":** the three calls run sequentially in the sandbox. The win -is collapsing 4 model turns into 1 — not parallel network I/O. +**Note on "parallel":** written this way the three calls run sequentially. Since +none of them depends on another, `call_tools` runs them at once and the merge +reads the slots by position: + +```typescript +// language: "typescript" +// input: { "repo": "octocat/Hello-World" } +const [owner, name] = (input.repo as string).split("/"); + +const [repo, issues, ci] = call_tools([ + { server: "github", tool: "get_repo", args: { owner, repo: name } }, + { server: "github", tool: "list_issues", args: { owner, repo: name, state: "open" } }, + { server: "ci", tool: "latest_pipeline", args: { project: input.repo } }, +]) as any[]; + +({ + repo: repo.ok ? { stars: repo.result.stargazers_count } : { error: repo.error.message }, + openIssues: issues.ok ? issues.result.length : null, + ci: ci.ok ? ci.result.status : "unknown", +}); +``` + +Now the win is both: 4 model turns collapse into 1, **and** the dashboard costs +one slow call instead of three. --- @@ -362,6 +427,12 @@ the per‑script call count with `max_tool_calls` and let the agent resume acros turns. (See [troubleshooting.md](troubleshooting.md) for the `setTimeout`‑is‑unavailable rationale.) +**With `call_tools`:** keep `max_parallel` at or below the server's +`max_concurrent_requests` — a batch wider than the cap does not go faster, it +just queues (or, with no `queue_size`, sheds the overflow into per‑slot +`queue_full` errors). The proxy‑side cap is the durable fix; `max_parallel` is +the script‑side courtesy. + **Replaces:** 100 individual round‑trips with ~10 bulk calls in one script. --- @@ -432,11 +503,15 @@ wall‑clock. An N‑step orchestration costs: Each eliminated round‑trip removes a full model turn — its latency, its generated tool‑call JSON, and its re‑reading of the intermediate result. For a -5‑step recipe that is roughly a **5×** reduction in model turns. Server‑side the -N tool calls still run sequentially (the sandbox is single‑threaded — see [the -sandbox contract](#the-sandbox-contract-read-this-first)), so `code_execution` -optimizes the *agent loop*, not raw upstream I/O. Quote round‑trip savings, not -parallelism, when you describe the win. +5‑step recipe that is roughly a **5×** reduction in model turns. + +Server‑side, sequential `call_tool` steps still run one at a time (the sandbox is +single‑threaded — see [the sandbox contract](#the-sandbox-contract-read-this-first)), +so a chained pipeline optimizes the *agent loop*, not raw upstream I/O. Where the +steps are **independent**, `call_tools` also cuts server‑side wall‑clock: N calls +cost about `ceil(N / max_parallel) × slowest-call` instead of their sum. Quote +parallelism only for `call_tools` batches; for chained recipes, quote round‑trip +savings. --- @@ -456,7 +531,8 @@ deliberate opt‑in): "enable_code_execution": true, "code_execution_timeout_ms": 120000, "code_execution_max_tool_calls": 0, - "code_execution_pool_size": 10 + "code_execution_pool_size": 10, + "code_execution_max_parallel": 8 } ``` diff --git a/docs/code_execution/overview.md b/docs/code_execution/overview.md index 0ed79eb5..caaf3ab1 100644 --- a/docs/code_execution/overview.md +++ b/docs/code_execution/overview.md @@ -109,7 +109,7 @@ Transform, filter, and aggregate data from multiple tool calls before returning │ JavaScript Runtime Pool │ │ - Acquires VM from pool (blocks if full) │ │ - Creates isolated sandbox │ -│ - Binds input global and call_tool() │ +│ - Binds input, call_tool(), call_tools() │ └────────────┬────────────────────────────────┘ │ ▼ @@ -135,7 +135,7 @@ Transform, filter, and aggregate data from multiple tool calls before returning 1. **Request Parsing**: Extract `code`, `input`, and `options` from the request 2. **Validation**: Verify timeout (1-600000ms) and max_tool_calls (>= 0) 3. **Pool Acquisition**: Acquire a JavaScript VM from the pool (blocks if all VMs are in use) -4. **Sandbox Setup**: Create isolated environment with `input` global and `call_tool()` function +4. **Sandbox Setup**: Create isolated environment with `input` global and the `call_tool()` / `call_tools()` functions 5. **Execution**: Run JavaScript with timeout enforcement and tool call tracking 6. **Result Extraction**: Validate result is JSON-serializable and return structured response 7. **Pool Release**: Return VM to pool for reuse @@ -158,6 +158,7 @@ The JavaScript execution environment is **heavily sandboxed** to prevent securit ✅ **Available:** - `input` - Global variable with request input data - `call_tool(serverName, toolName, args)` - Function to call upstream MCP tools +- `call_tools(requests, options)` - Function to call independent upstream tools in parallel (see [Pattern 5](#pattern-5-parallel-fan-out-with-call_tools)) - Modern JavaScript (ES2020+) standard library including Array, Object, String, Math, Date, JSON, Map, Set, Symbol, Promise, Proxy, Reflect ### Configuration & Limits @@ -167,10 +168,13 @@ The JavaScript execution environment is **heavily sandboxed** to prevent securit "enable_code_execution": false, // Must be explicitly enabled (default: false) "code_execution_timeout_ms": 120000, // Default: 2 minutes, max: 10 minutes "code_execution_max_tool_calls": 0, // Default: unlimited - "code_execution_pool_size": 10 // Default: 10 concurrent VMs + "code_execution_pool_size": 10, // Default: 10 concurrent VMs + "code_execution_max_parallel": 8 // Default: 8 concurrent calls per call_tools() batch (1-32) } ``` +`code_execution_max_parallel` is hot-reloaded and applies to executions that start after the change. It is not a request-level option: a script overrides it per batch with `call_tools(requests, {max_parallel})`, so precedence is per-batch override > `code_execution_max_parallel` > built-in 8. + **Per-Request Overrides:** ```javascript { @@ -348,6 +352,51 @@ The `code_execution` tool will appear in the tools list when an LLM agent connec })(); ``` +### Pattern 5: Parallel Fan-out with call_tools + +```javascript +// Independent calls — no element depends on another's result +var prs = call_tools( + [1, 2, 3, 4, 5].map(function (n) { + return {server: 'github', tool: 'get_pull_request', + args: {owner: 'acme', repo: 'api', pullNumber: n}}; + }), + {max_parallel: 5} +); + +var titles = prs.map(function (r) { + if (!r.ok) { return 'ERR: ' + r.error.code; } + return JSON.parse(r.result.content[0].text).title; +}); +({titles: titles}); +``` + +`call_tools(requests, options)` dispatches up to `max_parallel` elements at a +time and returns one slot per request, in input order — so the batch takes about +as long as its slowest element instead of the sum of all of them. Rules: + +- `requests`: array of `{server, tool, args?}`, at most 100 elements; `args` + defaults to `{}`. +- `options.max_parallel`: integer 1-32. Defaults to `code_execution_max_parallel` + (8). Unknown option keys are ignored. +- Each slot is the same envelope `call_tool()` returns, so a failing element + never poisons its siblings. +- Malformed arguments (not an array, bad element shape, sparse hole, bad + `max_parallel`, more than 100 elements) return a **single** + `{ok: false, error: {code: "INVALID_ARGS", ...}}` envelope naming the first + offending index, and nothing is dispatched. +- Every element costs one unit of `max_tool_calls`, checked in input order, and + the whole batch runs inside the execution timeout. +- Use it only for **independent** calls — chained steps still belong in a + sequential pipeline (Pattern 1). + +> **Per-server limits still apply.** [Concurrency limits](../configuration.md#concurrency-limits--request-queueing) +> are enforced inside the call path, never bypassed by batching. A server with +> `max_concurrent_requests: 1` and `queue_size: 9` serializes a 10-element batch; +> the same server with **no** `queue_size` sheds the overflow as per-slot +> `queue_full` errors. Configure `queue_size` headroom (or lower `max_parallel`) +> before fanning out against a limited server. + ## Error Handling ### JavaScript Errors diff --git a/docs/code_execution/troubleshooting.md b/docs/code_execution/troubleshooting.md index 5d1f562f..da1b7cba 100644 --- a/docs/code_execution/troubleshooting.md +++ b/docs/code_execution/troubleshooting.md @@ -38,6 +38,7 @@ Error: code_execution is disabled in configuration. Set 'enable_code_execution: "code_execution_timeout_ms": 120000, "code_execution_max_tool_calls": 0, "code_execution_pool_size": 10, + "code_execution_max_parallel": 8, "mcpServers": [...] } ``` @@ -90,6 +91,29 @@ mcpproxy call tool --tool-name=retrieve_tools --json_args='{"query":"code execut --- +### Error: "code_execution_max_parallel: must be between 1 and 32" + +**Symptom**: mcpproxy refuses to load the configuration file: +``` +config validation failed: code_execution_max_parallel: must be between 1 and 32 (or 0 for default) +``` + +**Cause**: `code_execution_max_parallel` is outside the supported range. + +**Solution**: Use a value between 1 and 32 (or omit the key / set `0` for the +default of 8): +```json +{ + "code_execution_max_parallel": 8 +} +``` + +The same range applies to the per-batch override — +`call_tools(requests, {max_parallel: 40})` returns a single `INVALID_ARGS` +envelope instead of dispatching. + +--- + ## Syntax Errors ### Error: "SyntaxError: Unexpected token" @@ -365,6 +389,9 @@ for (var i = 0; i < input.items.length; i++) { ``` **Cause**: Code called `call_tool()` more times than `max_tool_calls` allows. +Every element of a `call_tools()` batch counts as one call too, checked in input +order — tail elements over the limit come back as per-slot +`MAX_TOOL_CALLS_EXCEEDED` errors and are never dispatched. **Solution**: @@ -463,6 +490,79 @@ mcpproxy call tool --tool-name=upstream_servers \ --- +### Error: "call_tools: element N: ..." (INVALID_ARGS) + +**Symptom**: `call_tools()` returns a single envelope instead of an array of slots: +```json +{ + "ok": false, + "error": { + "code": "INVALID_ARGS", + "message": "call_tools: element 3: must be an object with server and tool" + } +} +``` + +**Cause**: The batch itself is malformed, so nothing was dispatched and no budget +was consumed. Triggers: `requests` is not an array, an element is not an object +with non-empty `server`/`tool` strings, a supplied `args` is not an object, the +array has a sparse hole, `options` is not an object, `max_parallel` is not an +integer in 1-32, or the batch exceeds 100 elements. + +**Solution**: Fix the element the message names, then check the result shape +before mapping over it: +```javascript +var slots = call_tools(requests, {max_parallel: 8}); +if (!Array.isArray(slots)) { + // whole batch rejected — slots is {ok:false, error:{...}} + ({error: slots.error.message}); +} else { + ({results: slots}); +} +``` + +For more than 100 items, chunk the array and issue one `call_tools()` per chunk. + +--- + +### Error: "upstream server X is busy: its concurrency limit (N) is saturated (queue_full)" + +**Symptom**: A `call_tools()` batch returns one success and many failed slots +mentioning a concurrency limit: +```json +[ + {"ok": true, "result": {...}}, + {"ok": false, "error": { + "code": "UPSTREAM_ERROR", + "message": "upstream server \"fragile-db\" is busy: its concurrency limit (1) is saturated (queue_full) — please retry shortly" + }} +] +``` + +**Cause**: The target server has a per-server concurrency limit +(`max_concurrent_requests`) with **no** `queue_size`. Batching never bypasses +those limits: calls over the cap are shed immediately, which is the server's +configured policy — one shed error per overflow element. + +**Solution**: Give the server queue headroom, or lower the batch concurrency to +match its cap: +```json +{ + "mcpServers": [ + { "name": "fragile-db", "command": "db-mcp", "max_concurrent_requests": 1, "queue_size": 20 } + ] +} +``` +```javascript +// Or match the cap from the script +call_tools(requests, {max_parallel: 1}); +``` + +Queued calls wait up to `queue_timeout`, and that wall-clock wait happens inside +the script's `timeout_ms` budget — size `queue_size` and `timeout_ms` together. + +--- + ## Serialization Errors ### Error: "Result contains non-JSON-serializable values" @@ -556,6 +656,24 @@ for (var i = 0; i < items.length; i++) { call_tool('api', 'get_batch', {ids: items}); ``` +**Run independent calls in parallel** (when the tool has no bulk variant): +```javascript +// Bad: N sequential calls — latency is the sum +for (var i = 0; i < items.length; i++) { + call_tool('api', 'get', {id: items[i]}); +} + +// Good: one batch — latency is about the slowest call +var slots = call_tools(items.map(function (id) { + return {server: 'api', tool: 'get', args: {id: id}}; +}), {max_parallel: 8}); +``` + +Only batch calls that do not depend on each other. Raise +`code_execution_max_parallel` (or `options.max_parallel`, max 32) if upstreams +can take the pressure — and check the target server's `max_concurrent_requests` / +`queue_size` first (see the `queue_full` entry above). + **Cache repeated calls**: ```javascript // Bad: Call same tool multiple times diff --git a/docs/configuration.md b/docs/configuration.md index 51f8055e..eaba31ec 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -288,6 +288,14 @@ tool-call endpoint returns `429` with `Retry-After`, and the activity log records the call with a `rejected` status carrying the reason (`queue_full` or `queue_timeout`) and scope (`server` or `global`). +**Batched sandbox calls.** A `call_tools()` batch from +[code execution](#code-execution) goes through the same admission path, so these +limits are never bypassed by batching. A server capped at +`max_concurrent_requests: 1` with `queue_size: 9` serializes a 10-element batch; +the same server with **no** `queue_size` returns one result and nine per-slot +`queue_full` errors. Give servers you fan out against enough `queue_size` +headroom (or keep `code_execution_max_parallel` at their cap). + **Hot reload.** All limits are hot-reloadable — edit the config file and the new values govern subsequent admissions without a restart. Running calls are never interrupted, but they keep counting against the new caps: after lowering a cap, @@ -1161,7 +1169,8 @@ See [Tool Quarantine](features/tool-quarantine.md) for complete details. "enable_code_execution": false, "code_execution_timeout_ms": 120000, "code_execution_max_tool_calls": 0, - "code_execution_pool_size": 10 + "code_execution_pool_size": 10, + "code_execution_max_parallel": 8 } ``` @@ -1171,9 +1180,24 @@ See [Tool Quarantine](features/tool-quarantine.md) for complete details. | `code_execution_timeout_ms` | integer | `120000` | Default timeout in milliseconds (1-600000, max 10 minutes) | | `code_execution_max_tool_calls` | integer | `0` | Maximum tool calls per execution (0 = unlimited) | | `code_execution_pool_size` | integer | `10` | Number of JavaScript VM instances in pool (1-100) | +| `code_execution_max_parallel` | integer | `8` | Default concurrency for `call_tools()` batches (1-32). Hot-reloaded; applies to executions that start after the change | Code execution supports both JavaScript (ES2020+) and TypeScript. TypeScript code is automatically transpiled via esbuild before execution. +Inside a script, `call_tool(server, tool, args)` runs one upstream tool at a time and `call_tools(requests, options)` fans out **independent** calls in parallel: + +```javascript +var slots = call_tools([ + {server: "github", tool: "get_pull_request", args: {owner: "acme", repo: "api", pullNumber: 1}}, + {server: "github", tool: "get_pull_request", args: {owner: "acme", repo: "api", pullNumber: 2}} +], {max_parallel: 5}); +// slots[i] is {ok: true, result} or {ok: false, error} for requests[i] +``` + +`requests` holds at most 100 elements; each costs one unit of `code_execution_max_tool_calls` budget. Concurrency precedence is `options.max_parallel` (1-32) > `code_execution_max_parallel` > built-in 8. + +> **Batching vs. per-server limits.** [Concurrency limits](#concurrency-limits--request-queueing) still govern each element. A server with `max_concurrent_requests` set and **no** `queue_size` sheds everything over the cap — a 10-element batch against `max_concurrent_requests: 1` returns 1 result and 9 per-slot `queue_full` errors. Give such servers `queue_size` headroom (or lower `max_parallel`) before fanning out against them. + See [Code Execution Documentation](code_execution/overview.md) for complete details. --- @@ -1448,6 +1472,7 @@ Here's a complete configuration example with all major sections: "code_execution_timeout_ms": 120000, "code_execution_max_tool_calls": 0, "code_execution_pool_size": 10, + "code_execution_max_parallel": 8, "read_only_mode": false, "disable_management": false, diff --git a/docs/configuration/config-file.md b/docs/configuration/config-file.md index b513b6f7..d40f36a6 100644 --- a/docs/configuration/config-file.md +++ b/docs/configuration/config-file.md @@ -38,6 +38,7 @@ MCPProxy uses a JSON configuration file located at `~/.mcpproxy/mcp_config.json` "code_execution_timeout_ms": 120000, "code_execution_max_tool_calls": 0, "code_execution_pool_size": 10, + "code_execution_max_parallel": 8, "features": { "enable_web_ui": true }, @@ -139,6 +140,31 @@ Both cadences are configurable globally, and can be overridden per server (see [ | `code_execution_timeout_ms` | integer | `120000` | Execution timeout in milliseconds | | `code_execution_max_tool_calls` | integer | `0` | Maximum tool calls (0 = unlimited) | | `code_execution_pool_size` | integer | `10` | VM pool size for code execution | +| `code_execution_max_parallel` | integer | `8` | Default concurrency for `call_tools()` batches (1-32) | + +Scripts call one tool at a time with `call_tool(server, tool, args)`, or fan out +independent calls with `call_tools(requests, options)`: + +```javascript +var slots = call_tools([ + {server: "github", tool: "get_pull_request", args: {owner: "acme", repo: "api", pullNumber: 1}}, + {server: "github", tool: "get_pull_request", args: {owner: "acme", repo: "api", pullNumber: 2}} +], {max_parallel: 5}); +// slots[i] is {ok: true, result} or {ok: false, error} for requests[i], in input order +``` + +`requests` takes up to 100 elements and each one costs a unit of +`code_execution_max_tool_calls`. Concurrency precedence is `options.max_parallel` +(1-32) > `code_execution_max_parallel` > built-in 8. The whole batch lives inside +the execution timeout. Changes to these keys hot-reload and apply to executions +that start afterwards. + +**Batching vs. per-server limits.** The [concurrency limits](#concurrency-limits--request-queueing) +below still govern every element. A server with `max_concurrent_requests` set and +**no** `queue_size` sheds everything past the cap, so a 10-element batch against +`max_concurrent_requests: 1` comes back as 1 result and 9 per-slot `queue_full` +errors. Give such servers `queue_size` headroom (or lower `max_parallel`) before +fanning out against them. ### Update Check Settings diff --git a/docs/features/code-execution.md b/docs/features/code-execution.md index 5f4cb116..be3e789a 100644 --- a/docs/features/code-execution.md +++ b/docs/features/code-execution.md @@ -29,7 +29,8 @@ Enable code execution in your config: "enable_code_execution": true, "code_execution_timeout_ms": 120000, "code_execution_max_tool_calls": 0, - "code_execution_pool_size": 10 + "code_execution_pool_size": 10, + "code_execution_max_parallel": 8 } ``` @@ -39,6 +40,7 @@ Enable code execution in your config: | `code_execution_timeout_ms` | integer | `120000` | Execution timeout (2 minutes) | | `code_execution_max_tool_calls` | integer | `0` | Max tool calls (0 = unlimited) | | `code_execution_pool_size` | integer | `10` | Number of VM instances to pool | +| `code_execution_max_parallel` | integer | `8` | Default concurrency for `call_tools()` batches (1-32) | ## CLI Usage @@ -86,6 +88,33 @@ var result = call_tool('github', 'create_issue', { }); ``` +#### call_tools(requests, options) + +Execute **independent** tools in parallel and get one result slot per request, in +input order: + +```javascript +var slots = call_tools([ + {server: 'github', tool: 'get_pull_request', args: {owner: 'acme', repo: 'api', pullNumber: 1}}, + {server: 'github', tool: 'get_pull_request', args: {owner: 'acme', repo: 'api', pullNumber: 2}}, + {server: 'ci', tool: 'latest_pipeline', args: {project: 'acme/api'}} +], {max_parallel: 5}); + +slots.map(function (s) { return s.ok ? s.result : 'ERR: ' + s.error.code; }); +``` + +- `requests`: array of `{server, tool, args?}` (max 100). `args` defaults to `{}`. +- `options.max_parallel`: 1-32, defaults to `code_execution_max_parallel` (8). +- Each slot is the same `{ok: true, result}` / `{ok: false, error}` envelope + `call_tool()` returns, so one failing element never fails its siblings. +- Malformed arguments return a single `{ok: false, error}` envelope naming the + first offending element index, and nothing is dispatched. +- Every element costs one unit of `max_tool_calls`, checked in input order. +- Per-server concurrency limits still apply: a server capped with + `max_concurrent_requests` and **no** `queue_size` sheds the overflow as + per-slot `queue_full` errors. Set `queue_size` (or lower `max_parallel`) when + fanning out against a limited server. + #### log(message) Log a message (visible in tool response): @@ -239,6 +268,6 @@ const user: User = { name: input.username }; 1. **Keep code simple**: Complex logic is harder to debug 2. **Handle errors**: Use try/catch for tool calls -3. **Minimize tool calls**: Batch operations when possible +3. **Minimize tool calls**: Batch operations when possible; run independent calls with `call_tools()` instead of a sequential loop 4. **Use logging**: Add log() calls for debugging 5. **Test locally**: Use CLI to test before integrating diff --git a/frontend/src/views/settings/fields.ts b/frontend/src/views/settings/fields.ts index 4a6873de..53149fa9 100644 --- a/frontend/src/views/settings/fields.ts +++ b/frontend/src/views/settings/fields.ts @@ -325,6 +325,7 @@ export const ADVANCED_ACCORDIONS: SettingsAccordion[] = [ { key: 'code_execution_timeout_ms', label: 'Max run time per execution (ms)', control: 'number', min: 1, max: 600000 }, { key: 'code_execution_max_tool_calls', label: 'Max tool calls per execution', help: '0 = unlimited.', control: 'number', min: 0 }, { key: 'code_execution_pool_size', label: 'JavaScript runtime pool size', help: 'How many sandboxes run concurrently.', control: 'number', min: 1, max: 100 }, + { key: 'code_execution_max_parallel', label: 'Parallel calls per call_tools() batch', help: 'Default concurrency for batched tool calls; a script can lower it per call.', control: 'number', min: 1, max: 32 }, ], }, { diff --git a/frontend/tests/unit/settings-code-execution-max-parallel.spec.ts b/frontend/tests/unit/settings-code-execution-max-parallel.spec.ts new file mode 100644 index 00000000..f34f861a --- /dev/null +++ b/frontend/tests/unit/settings-code-execution-max-parallel.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest' +import { ADVANCED_ACCORDIONS, validateField, type SettingField } from '../../src/views/settings/fields' + +// Spec 096 T014: `code_execution_max_parallel` (default concurrency for +// call_tools() batches) must be reachable from Settings like its code-execution +// siblings. The 1–32 bound is not cosmetic — it mirrors the Go validator +// (internal/config/config.go), so an out-of-range value the form accepts would +// be rejected on save. +const KEY = 'code_execution_max_parallel' + +function field(): SettingField | undefined { + return ADVANCED_ACCORDIONS.find((a) => a.id === 'code-execution')?.fields.find((f) => f.key === KEY) +} + +describe('Settings — code_execution_max_parallel (spec 096)', () => { + it('lives in the code-execution accordion as a number control', () => { + const f = field() + expect(f, `the "code-execution" accordion must contain a "${KEY}" field`).toBeDefined() + expect(f!.control).toBe('number') + expect(f!.label).not.toBe('') + }) + + it('bounds the value to 1–32, matching the backend validator', () => { + const f = field()! + expect(f.min).toBe(1) + expect(f.max).toBe(32) + expect(validateField(f, 8)).toBeNull() + expect(validateField(f, 1)).toBeNull() + expect(validateField(f, 32)).toBeNull() + expect(validateField(f, 0)).not.toBeNull() + expect(validateField(f, 33)).not.toBeNull() + }) + + it('is hot-reloadable — no restart flag and no danger confirmation', () => { + const f = field()! + expect(f.restart).toBeUndefined() + expect(f.danger).toBeUndefined() + }) +}) diff --git a/internal/config/config.go b/internal/config/config.go index 616bf0a3..2b51c485 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -416,6 +416,7 @@ type Config struct { CodeExecutionTimeoutMs int `json:"code_execution_timeout_ms,omitempty" mapstructure:"code-execution-timeout-ms"` // Timeout in milliseconds (default: 120000, max: 600000) CodeExecutionMaxToolCalls int `json:"code_execution_max_tool_calls,omitempty" mapstructure:"code-execution-max-tool-calls"` // Max tool calls per execution (0 = unlimited, default: 0) CodeExecutionPoolSize int `json:"code_execution_pool_size,omitempty" mapstructure:"code-execution-pool-size"` // JavaScript runtime pool size (default: 10) + CodeExecutionMaxParallel int `json:"code_execution_max_parallel,omitempty" mapstructure:"code-execution-max-parallel"` // Default concurrency for call_tools() batches (1-32, default: 8) // ToolResponseSessionRiskWarning controls whether the prose `warning` field // is included in the `session_risk` object returned by `retrieve_tools`. @@ -1701,6 +1702,7 @@ func DefaultConfig() *Config { CodeExecutionTimeoutMs: 120000, // 2 minutes (120,000ms) CodeExecutionMaxToolCalls: 0, // Unlimited by default (0 = no limit) CodeExecutionPoolSize: 10, // 10 JavaScript runtime instances + CodeExecutionMaxParallel: 8, // 8 concurrent upstream calls per call_tools() batch // Session risk warning prose disabled by default to reduce token overhead // and LLM distraction in trusted setups (issue #406). Structured risk @@ -2160,6 +2162,13 @@ func (c *Config) ValidateDetailed() []ValidationError { }) } + if c.CodeExecutionMaxParallel != 0 && (c.CodeExecutionMaxParallel < 1 || c.CodeExecutionMaxParallel > 32) { + errors = append(errors, ValidationError{ + Field: "code_execution_max_parallel", + Message: "must be between 1 and 32 (or 0 for default)", + }) + } + // Validate routing mode (Spec 031) if c.RoutingMode != "" { validRoutingModes := map[string]bool{ @@ -2424,6 +2433,9 @@ func (c *Config) Validate() error { if c.CodeExecutionPoolSize <= 0 { c.CodeExecutionPoolSize = 10 // 10 JavaScript runtime instances } + if c.CodeExecutionMaxParallel <= 0 { + c.CodeExecutionMaxParallel = 8 // 8 concurrent upstream calls per call_tools() batch + } // CodeExecutionMaxToolCalls defaults to 0 (unlimited), which is valid // Apply routing mode default (Spec 031) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 22163b7b..160562ce 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1861,3 +1861,74 @@ func TestTrustedHostsLoadAndEnvOverride(t *testing.T) { assert.Empty(t, cfg.TrustedHosts) }) } + +// TestCodeExecutionMaxParallel covers the Spec 096 config field end to end: +// the built-in default, an operator value surviving load, the accepted range, +// and the absent/zero → default resolution the sandbox relies on (a zero would +// otherwise mean "no workers" and stall every batch). +func TestCodeExecutionMaxParallel(t *testing.T) { + t.Run("default is 8", func(t *testing.T) { + assert.Equal(t, 8, DefaultConfig().CodeExecutionMaxParallel) + }) + + t.Run("absent value resolves to the default", func(t *testing.T) { + cfg := &Config{} + require.NoError(t, cfg.Validate()) + assert.Equal(t, 8, cfg.CodeExecutionMaxParallel) + }) + + t.Run("zero resolves to the default", func(t *testing.T) { + cfg := &Config{CodeExecutionMaxParallel: 0} + require.NoError(t, cfg.Validate()) + assert.Equal(t, 8, cfg.CodeExecutionMaxParallel) + }) + + t.Run("explicit value survives", func(t *testing.T) { + cfg := &Config{CodeExecutionMaxParallel: 16} + require.NoError(t, cfg.Validate()) + assert.Equal(t, 16, cfg.CodeExecutionMaxParallel) + }) + + t.Run("range validation", func(t *testing.T) { + cases := []struct { + name string + value int + wantErr bool + }{ + {name: "zero means default", value: 0}, + {name: "lower bound", value: 1}, + {name: "upper bound", value: 32}, + {name: "below range", value: -1, wantErr: true}, + {name: "above range", value: 33, wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := &Config{Listen: "127.0.0.1:8080", CodeExecutionMaxParallel: tc.value} + errs := cfg.ValidateDetailed() + var found bool + for _, e := range errs { + if e.Field == "code_execution_max_parallel" { + found = true + } + } + assert.Equal(t, tc.wantErr, found, "validation errors: %v", errs) + }) + } + }) + + t.Run("value loads from the config file", func(t *testing.T) { + tmp := t.TempDir() + cfgPath := filepath.Join(tmp, "mcp_config.json") + raw, err := json.Marshal(map[string]any{ + "listen": "127.0.0.1:0", + "data_dir": tmp, + "code_execution_max_parallel": 4, + }) + require.NoError(t, err) + require.NoError(t, os.WriteFile(cfgPath, raw, 0o600)) + + cfg, err := LoadFromFile(cfgPath) + require.NoError(t, err) + assert.Equal(t, 4, cfg.CodeExecutionMaxParallel) + }) +} diff --git a/internal/jsruntime/batch_test.go b/internal/jsruntime/batch_test.go new file mode 100644 index 00000000..4ee64023 --- /dev/null +++ b/internal/jsruntime/batch_test.go @@ -0,0 +1,1000 @@ +package jsruntime + +import ( + "context" + "fmt" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" +) + +// batchStub is a ToolCaller that records how a batch was dispatched: how many +// calls were made, how many ran at once (high-water mark), the context each +// one ran under, and — optionally — a fixed latency or a block until the +// context is cancelled. +type batchStub struct { + mu sync.Mutex + latency time.Duration + blockOnCtx bool + dispatched int + inFlight int + highWater int + cancelled int + seenTools []string + seenArgs map[string]map[string]interface{} + seenCtxs []context.Context + results map[string]interface{} + errors map[string]error + defaultFunc func(server, tool string) interface{} +} + +func newBatchStub() *batchStub { + return &batchStub{ + results: make(map[string]interface{}), + errors: make(map[string]error), + seenArgs: make(map[string]map[string]interface{}), + } +} + +func (s *batchStub) CallTool(ctx context.Context, serverName, toolName string, args map[string]interface{}) (interface{}, error) { + key := serverName + ":" + toolName + + s.mu.Lock() + s.dispatched++ + s.inFlight++ + if s.inFlight > s.highWater { + s.highWater = s.inFlight + } + s.seenTools = append(s.seenTools, toolName) + s.seenArgs[toolName] = args + s.seenCtxs = append(s.seenCtxs, ctx) + latency, blockOnCtx := s.latency, s.blockOnCtx + err, hasErr := s.errors[key] + result, hasResult := s.results[key] + defaultFunc := s.defaultFunc + s.mu.Unlock() + + switch { + case blockOnCtx: + <-ctx.Done() + s.mu.Lock() + s.cancelled++ + s.mu.Unlock() + case latency > 0: + time.Sleep(latency) + } + + s.mu.Lock() + s.inFlight-- + s.mu.Unlock() + + if blockOnCtx { + return nil, ctx.Err() + } + if hasErr { + return nil, err + } + if hasResult { + return result, nil + } + if defaultFunc != nil { + return defaultFunc(serverName, toolName), nil + } + return map[string]interface{}{"success": true, "tool": toolName}, nil +} + +func (s *batchStub) stats() (dispatched, highWater, cancelled int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.dispatched, s.highWater, s.cancelled +} + +// batchSlots runs a script that returns call_tools() slots and exports them. +func batchSlots(t *testing.T, caller ToolCaller, code string, opts ExecutionOptions) ([]interface{}, *ExecutionContext) { + t.Helper() + + result, ec := execute(context.Background(), caller, code, opts) + if !result.Ok { + t.Fatalf("script failed: %v", result.Error) + } + slots, ok := result.Value.([]interface{}) + if !ok { + t.Fatalf("call_tools returned %T, want an array: %#v", result.Value, result.Value) + } + return slots, ec +} + +func slotError(t *testing.T, slot interface{}) (code, message string) { + t.Helper() + envelope, ok := slot.(map[string]interface{}) + if !ok { + t.Fatalf("slot is %T, want an object", slot) + } + if ok, _ := envelope["ok"].(bool); ok { + t.Fatalf("slot succeeded, expected an error: %#v", envelope) + } + return envelopeError(t, envelope) +} + +// TestCallToolsReturnsOrderedSlots (US1): every element gets a slot at its own +// index, whatever order the workers finished in. +func TestCallToolsReturnsOrderedSlots(t *testing.T) { + stub := newBatchStub() + stub.defaultFunc = func(_, tool string) interface{} { + return map[string]interface{}{"echo": tool} + } + + code := ` + var reqs = []; + for (var i = 0; i < 10; i++) { reqs.push({server: "s", tool: "t" + i, args: {i: i}}); } + call_tools(reqs) + ` + slots, _ := batchSlots(t, stub, code, ExecutionOptions{TimeoutMs: 10000}) + + if len(slots) != 10 { + t.Fatalf("got %d slots, want 10", len(slots)) + } + for i, slot := range slots { + envelope, ok := slot.(map[string]interface{}) + if !ok { + t.Fatalf("slot %d is %T, want an object", i, slot) + } + if ok, _ := envelope["ok"].(bool); !ok { + t.Fatalf("slot %d failed: %#v", i, envelope) + } + payload, ok := envelope["result"].(map[string]interface{}) + if !ok { + t.Fatalf("slot %d result is %T, want an object", i, envelope["result"]) + } + if want := fmt.Sprintf("t%d", i); payload["echo"] != want { + t.Errorf("slot %d carries %v, want %q — slots must follow input order", i, payload["echo"], want) + } + } + + dispatched, _, _ := stub.stats() + if dispatched != 10 { + t.Errorf("dispatched %d calls, want 10", dispatched) + } +} + +// TestCallToolsRunsInParallel (SC-001 shape): a fan-out of independent calls +// costs roughly the slowest element, not their sum. +func TestCallToolsRunsInParallel(t *testing.T) { + const ( + elements = 10 + latency = 50 * time.Millisecond + ) + stub := newBatchStub() + stub.latency = latency + + code := ` + var reqs = []; + for (var i = 0; i < 10; i++) { reqs.push({server: "s", tool: "t" + i, args: {}}); } + call_tools(reqs) + ` + start := time.Now() + slots, _ := batchSlots(t, stub, code, ExecutionOptions{TimeoutMs: 30000}) + elapsed := time.Since(start) + + if len(slots) != elements { + t.Fatalf("got %d slots, want %d", len(slots), elements) + } + serial := time.Duration(elements) * latency + if budget := serial * 35 / 100; elapsed >= budget { + t.Errorf("batch took %v, want < %v (35%% of the %v serial equivalent)", elapsed, budget, serial) + } +} + +// TestCallToolsEmptyBatch: an empty batch is a no-op — an empty array back, no +// dispatch, no budget consumed. +func TestCallToolsEmptyBatch(t *testing.T) { + stub := newBatchStub() + + slots, ec := batchSlots(t, stub, `call_tools([])`, ExecutionOptions{MaxToolCalls: 3, TimeoutMs: 10000}) + + if len(slots) != 0 { + t.Errorf("got %d slots, want 0", len(slots)) + } + if dispatched, _, _ := stub.stats(); dispatched != 0 { + t.Errorf("dispatched %d calls for an empty batch", dispatched) + } + if len(ec.ToolCalls) != 0 { + t.Errorf("recorded %d tool calls for an empty batch", len(ec.ToolCalls)) + } +} + +// TestCallToolsSingleElementMatchesLoneCall: one element through call_tools() +// must be indistinguishable from the same request through call_tool(). +func TestCallToolsSingleElementMatchesLoneCall(t *testing.T) { + result := map[string]interface{}{"content": []interface{}{map[string]interface{}{"type": "text", "text": "hi"}}} + + batchCaller := newBatchStub() + batchCaller.results["s:t"] = result + slots, batchCtx := batchSlots(t, batchCaller, `call_tools([{server: "s", tool: "t", args: {}}])`, ExecutionOptions{TimeoutMs: 10000}) + if len(slots) != 1 { + t.Fatalf("got %d slots, want 1", len(slots)) + } + + loneCaller := newBatchStub() + loneCaller.results["s:t"] = result + lone, loneCtx := execute(context.Background(), loneCaller, `call_tool("s", "t", {})`, ExecutionOptions{TimeoutMs: 10000}) + if !lone.Ok { + t.Fatalf("lone call script failed: %v", lone.Error) + } + + if !reflect.DeepEqual(slots[0], lone.Value) { + t.Errorf("batch slot = %#v, lone call_tool = %#v", slots[0], lone.Value) + } + if len(batchCtx.ToolCalls) != len(loneCtx.ToolCalls) { + t.Errorf("batch recorded %d tool calls, lone call recorded %d", len(batchCtx.ToolCalls), len(loneCtx.ToolCalls)) + } + if len(batchCtx.ToolCalls) == 1 { + if batchCtx.ToolCalls[0].ServerName != "s" || batchCtx.ToolCalls[0].ToolName != "t" || !batchCtx.ToolCalls[0].Success { + t.Errorf("batch record = %#v", batchCtx.ToolCalls[0]) + } + } +} + +// TestCallToolsResultsUseWireShape: slot results are the documented JSON wire +// shape, not the live Go value — the same guarantee tool_result_test.go pins +// for call_tool(). +func TestCallToolsResultsUseWireShape(t *testing.T) { + stub := newBatchStub() + stub.results["s:t"] = &mcp.CallToolResult{ + Content: []mcp.Content{mcp.TextContent{Type: "text", Text: `{"hello":"world"}`}}, + } + + code := ` + var slots = call_tools([{server: "s", tool: "t"}]); + var r = slots[0]; + if (!r.ok) throw new Error("call_tools failed: " + JSON.stringify(r.error)); + ({ + hello: JSON.parse(r.result.content[0].text).hello, + pascal: typeof r.result.Content, + method: typeof r.result.MarshalJSON + }) + ` + result := Execute(context.Background(), stub, code, ExecutionOptions{TimeoutMs: 10000}) + if !result.Ok { + t.Fatalf("script failed: %v", result.Error) + } + values, ok := result.Value.(map[string]interface{}) + if !ok { + t.Fatalf("expected a map, got %T", result.Value) + } + if values["hello"] != "world" { + t.Errorf("hello = %v, want world", values["hello"]) + } + if values["pascal"] != "undefined" { + t.Errorf("Go field name Content leaked into the slot result: typeof = %v", values["pascal"]) + } + if values["method"] != "undefined" { + t.Errorf("Go method MarshalJSON leaked into the slot result: typeof = %v", values["method"]) + } +} + +// ctxCapturingCaller records the context each dispatch ran under. +type ctxCapturingCaller struct { + ctx context.Context +} + +func (c *ctxCapturingCaller) CallTool(ctx context.Context, _, _ string, _ map[string]interface{}) (interface{}, error) { + c.ctx = ctx + return map[string]interface{}{"success": true}, nil +} + +// TestExecuteWiresCancellableExecutionContext pins the context batch workers +// run under (T003): Execute hands the ExecutionContext its timeout context, so +// in-flight upstream calls are cancelled when the execution ends instead of +// being orphaned. +func TestExecuteWiresCancellableExecutionContext(t *testing.T) { + result, ec := execute(context.Background(), newMockToolCaller(), `1 + 1`, ExecutionOptions{TimeoutMs: 5000}) + if !result.Ok { + t.Fatalf("script failed: %v", result.Error) + } + + if ec.ctx == nil { + t.Fatal("Execute must wire an execution context for batch workers") + } + if _, hasDeadline := ec.ctx.Deadline(); !hasDeadline { + t.Error("the execution context must carry the execution timeout") + } + if ec.ctx.Err() == nil { + t.Error("the execution context must be cancelled once Execute returns") + } +} + +// TestExecutionContextFallsBackToBackground: an ExecutionContext built outside +// Execute has no wired context; the batch path must still have one to dispatch +// under rather than panicking on a nil context. +func TestExecutionContextFallsBackToBackground(t *testing.T) { + ec := newExecutionContext(newMockToolCaller(), ExecutionOptions{}) + if ec.executionCtx() == nil { + t.Fatal("executionCtx() must never return nil") + } +} + +// TestLoneCallToolKeepsBackgroundContext: threading the execution context into +// call_tool() would change when a lone call is cancelled, which is not part of +// this feature. The lone path stays on context.Background(). +func TestLoneCallToolKeepsBackgroundContext(t *testing.T) { + caller := &ctxCapturingCaller{} + result := Execute(context.Background(), caller, `call_tool("s", "t", {})`, ExecutionOptions{TimeoutMs: 5000}) + if !result.Ok { + t.Fatalf("script failed: %v", result.Error) + } + if caller.ctx == nil { + t.Fatal("call_tool did not dispatch") + } + if caller.ctx.Done() != nil { + t.Error("lone call_tool must keep dispatching on context.Background()") + } +} + +// loneCallEnvelope runs a single call_tool() through Execute and returns the +// envelope the script saw, so batch behaviour can be compared against the +// lone-call path byte for byte instead of against a restatement of it. +func loneCallEnvelope(t *testing.T, caller ToolCaller, server, tool string, opts ExecutionOptions) map[string]interface{} { + t.Helper() + + result := Execute(context.Background(), caller, `call_tool(`+quoteJS(server)+`, `+quoteJS(tool)+`, {})`, opts) + if !result.Ok { + t.Fatalf("lone call_tool script failed: %v", result.Error) + } + envelope, ok := result.Value.(map[string]interface{}) + if !ok { + t.Fatalf("lone call_tool returned %T, want map", result.Value) + } + return envelope +} + +func quoteJS(s string) string { + return `"` + s + `"` +} + +func envelopeError(t *testing.T, envelope map[string]interface{}) (code, message string) { + t.Helper() + errObj, ok := envelope["error"].(map[string]interface{}) + if !ok { + t.Fatalf("envelope carries no error object: %#v", envelope) + } + code, _ = errObj["code"].(string) + message, _ = errObj["message"].(string) + return code, message +} + +// TestCheckDispatchGatesMatchesLoneCall pins the extracted gate helper (T002) +// against the envelopes a lone call_tool() produces for the same execution +// context. The batch path calls the helper directly, so any drift here is +// drift between the two enforcement paths. +func TestCheckDispatchGatesMatchesLoneCall(t *testing.T) { + tests := []struct { + name string + opts ExecutionOptions + server string + tool string + wantCode string + wantPerm string + }{ + { + name: "allow-list violation", + opts: ExecutionOptions{AllowedServers: []string{"allowed"}}, + server: "other", + tool: "t", + wantCode: string(ErrorCodeServerNotAllowed), + }, + { + name: "deny-all profile with an empty allow-list", + opts: ExecutionOptions{RestrictToAllowed: true}, + server: "any", + tool: "t", + wantCode: string(ErrorCodeServerNotAllowed), + }, + { + name: "agent token cannot reach the server", + opts: ExecutionOptions{ + AuthContext: &AuthInfo{Type: "agent", AgentName: "a", AllowedServers: []string{"other"}, Permissions: []string{"read"}}, + }, + server: "s", + tool: "t", + wantCode: string(ErrorCodeAccessDenied), + }, + { + name: "agent token lacks the required permission tier", + opts: ExecutionOptions{ + AuthContext: &AuthInfo{Type: "agent", AgentName: "a", AllowedServers: []string{"s"}, Permissions: []string{"read"}}, + ToolAnnotationFunc: func(string, string) string { return "destructive" }, + }, + server: "s", + tool: "t", + wantCode: string(ErrorCodePermissionDenied), + }, + { + name: "allowed call reports the required permission tier", + opts: ExecutionOptions{ + AuthContext: &AuthInfo{Type: "agent", AgentName: "a", AllowedServers: []string{"s"}, Permissions: []string{"read", "write"}}, + ToolAnnotationFunc: func(string, string) string { return "write" }, + }, + server: "s", + tool: "t", + wantPerm: "write", + }, + { + name: "no restrictions", + opts: ExecutionOptions{}, + server: "s", + tool: "t", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ec := newExecutionContext(newMockToolCaller(), tc.opts) + gateErr, perm := ec.checkDispatchGates(tc.server, tc.tool) + + if tc.wantCode == "" { + if gateErr != nil { + t.Fatalf("expected the call to pass the gates, got %#v", gateErr) + } + if perm != tc.wantPerm { + t.Errorf("required permission = %q, want %q", perm, tc.wantPerm) + } + return + } + + if gateErr == nil { + t.Fatalf("expected gate error %s, got none", tc.wantCode) + } + gotCode, gotMessage := envelopeError(t, gateErr) + + lone := loneCallEnvelope(t, newMockToolCaller(), tc.server, tc.tool, tc.opts) + wantCode, wantMessage := envelopeError(t, lone) + + if gotCode != wantCode || gotCode != tc.wantCode { + t.Errorf("code = %q, lone call_tool = %q, expected %q", gotCode, wantCode, tc.wantCode) + } + if gotMessage != wantMessage { + t.Errorf("message = %q, lone call_tool = %q", gotMessage, wantMessage) + } + if ok, _ := gateErr["ok"].(bool); ok { + t.Errorf("gate error envelope must carry ok=false: %#v", gateErr) + } + }) + } +} + +// TestBudgetIsCheckedBeforeTheScopeGates pins the check ORDER the batch +// pre-dispatch pass must reproduce: an exhausted budget wins over a +// disallowed server, on both primitives. +func TestBudgetIsCheckedBeforeTheScopeGates(t *testing.T) { + caller := newMockToolCaller() + opts := ExecutionOptions{MaxToolCalls: 1, AllowedServers: []string{"allowed"}} + + code := ` + call_tool("allowed", "t", {}); + call_tool("denied", "t", {}); + ` + result := Execute(context.Background(), caller, code, opts) + if !result.Ok { + t.Fatalf("script failed: %v", result.Error) + } + envelope, ok := result.Value.(map[string]interface{}) + if !ok { + t.Fatalf("expected an envelope, got %T", result.Value) + } + gotCode, _ := envelopeError(t, envelope) + if gotCode != string(ErrorCodeMaxToolCallsExceeded) { + t.Errorf("code = %q, want %q — the budget gate must run before the allow-list gate", + gotCode, string(ErrorCodeMaxToolCallsExceeded)) + } +} + +// TestCallToolsIsolatesUpstreamFailures (US2): a failing element fails its own +// slot only. +func TestCallToolsIsolatesUpstreamFailures(t *testing.T) { + stub := newBatchStub() + stub.errors["s:t3"] = fmt.Errorf("upstream exploded") + + code := ` + var reqs = []; + for (var i = 0; i < 5; i++) { reqs.push({server: "s", tool: "t" + i, args: {}}); } + call_tools(reqs) + ` + slots, ec := batchSlots(t, stub, code, ExecutionOptions{TimeoutMs: 10000}) + + if len(slots) != 5 { + t.Fatalf("got %d slots, want 5", len(slots)) + } + for i, slot := range slots { + envelope := slot.(map[string]interface{}) + gotOK, _ := envelope["ok"].(bool) + if i == 3 { + if gotOK { + t.Fatalf("slot 3 succeeded, expected the upstream failure") + } + code, message := slotError(t, slot) + if code != string(ErrorCodeUpstreamError) { + t.Errorf("slot 3 code = %q, want %q", code, string(ErrorCodeUpstreamError)) + } + if message != "upstream exploded" { + t.Errorf("slot 3 message = %q, want the upstream error text", message) + } + continue + } + if !gotOK { + t.Errorf("slot %d failed because a sibling failed: %#v", i, envelope) + } + } + + if len(ec.ToolCalls) != 5 { + t.Fatalf("recorded %d tool calls, want 5 (one per dispatched element)", len(ec.ToolCalls)) + } + for i, record := range ec.ToolCalls { + if want := fmt.Sprintf("t%d", i); record.ToolName != want { + t.Errorf("record %d is for %q, want %q — records must land in input order", i, record.ToolName, want) + } + if record.Success == (i == 3) { + t.Errorf("record %d success = %v", i, record.Success) + } + } +} + +// TestCallToolsEnforcesScopePerElement (US2): a scope violation fails one slot +// with the same code a lone call_tool() would return, and is never dispatched. +func TestCallToolsEnforcesScopePerElement(t *testing.T) { + stub := newBatchStub() + + code := `call_tools([ + {server: "allowed", tool: "t", args: {}}, + {server: "denied", tool: "t", args: {}}, + {server: "allowed", tool: "u", args: {}} + ])` + slots, ec := batchSlots(t, stub, code, ExecutionOptions{AllowedServers: []string{"allowed"}, TimeoutMs: 10000}) + + if len(slots) != 3 { + t.Fatalf("got %d slots, want 3", len(slots)) + } + if ok, _ := slots[0].(map[string]interface{})["ok"].(bool); !ok { + t.Errorf("slot 0 failed: %#v", slots[0]) + } + if ok, _ := slots[2].(map[string]interface{})["ok"].(bool); !ok { + t.Errorf("slot 2 failed: %#v", slots[2]) + } + + gotCode, gotMessage := slotError(t, slots[1]) + wantCode, wantMessage := envelopeError(t, loneCallEnvelope(t, newBatchStub(), "denied", "t", + ExecutionOptions{AllowedServers: []string{"allowed"}})) + if gotCode != wantCode || gotMessage != wantMessage { + t.Errorf("slot 1 = %s/%q, lone call_tool = %s/%q", gotCode, gotMessage, wantCode, wantMessage) + } + + if dispatched, _, _ := stub.stats(); dispatched != 2 { + t.Errorf("dispatched %d calls, want 2 — a denied element must not reach the upstream", dispatched) + } + if len(ec.ToolCalls) != 2 { + t.Errorf("recorded %d tool calls, want 2 — a denied element records nothing, as with call_tool()", len(ec.ToolCalls)) + } +} + +// TestCallToolsRespectsToolCallBudget (US2): the budget is consumed in input +// order and the elements past it are refused before dispatch, so a batch can +// never overshoot max_tool_calls. +func TestCallToolsRespectsToolCallBudget(t *testing.T) { + stub := newBatchStub() + + code := ` + var reqs = []; + for (var i = 0; i < 5; i++) { reqs.push({server: "s", tool: "t" + i, args: {}}); } + call_tools(reqs) + ` + slots, ec := batchSlots(t, stub, code, ExecutionOptions{MaxToolCalls: 2, TimeoutMs: 10000}) + + if len(slots) != 5 { + t.Fatalf("got %d slots, want 5", len(slots)) + } + for i := 0; i < 2; i++ { + if ok, _ := slots[i].(map[string]interface{})["ok"].(bool); !ok { + t.Errorf("slot %d failed, expected it to fit the budget: %#v", i, slots[i]) + } + } + for i := 2; i < 5; i++ { + gotCode, _ := slotError(t, slots[i]) + if gotCode != string(ErrorCodeMaxToolCallsExceeded) { + t.Errorf("slot %d code = %q, want %q", i, gotCode, string(ErrorCodeMaxToolCallsExceeded)) + } + } + + if dispatched, _, _ := stub.stats(); dispatched != 2 { + t.Errorf("dispatched %d calls, want 2 — over-budget elements must not be dispatched", dispatched) + } + if len(ec.ToolCalls) != 2 { + t.Errorf("recorded %d tool calls, want 2", len(ec.ToolCalls)) + } +} + +// TestCallToolsBudgetCountsEarlierCalls: the budget a batch sees includes the +// calls the script already made, so call_tool() and call_tools() share one +// allowance. +func TestCallToolsBudgetCountsEarlierCalls(t *testing.T) { + stub := newBatchStub() + + code := ` + call_tool("s", "first", {}); + call_tools([{server: "s", tool: "a"}, {server: "s", tool: "b"}]) + ` + slots, _ := batchSlots(t, stub, code, ExecutionOptions{MaxToolCalls: 2, TimeoutMs: 10000}) + + if len(slots) != 2 { + t.Fatalf("got %d slots, want 2", len(slots)) + } + if ok, _ := slots[0].(map[string]interface{})["ok"].(bool); !ok { + t.Errorf("slot 0 failed, expected the last unit of budget to cover it: %#v", slots[0]) + } + if gotCode, _ := slotError(t, slots[1]); gotCode != string(ErrorCodeMaxToolCallsExceeded) { + t.Errorf("slot 1 code = %q, want %q", gotCode, string(ErrorCodeMaxToolCallsExceeded)) + } +} + +// TestCallToolsSerializationFailureIsPerSlot (US2): a result that cannot be +// turned into JSON fails its slot, not the batch. +func TestCallToolsSerializationFailureIsPerSlot(t *testing.T) { + stub := newBatchStub() + stub.results["s:bad"] = map[string]interface{}{"ch": make(chan int)} + + slots, ec := batchSlots(t, stub, `call_tools([{server: "s", tool: "good"}, {server: "s", tool: "bad"}])`, + ExecutionOptions{TimeoutMs: 10000}) + + if len(slots) != 2 { + t.Fatalf("got %d slots, want 2", len(slots)) + } + if ok, _ := slots[0].(map[string]interface{})["ok"].(bool); !ok { + t.Errorf("slot 0 failed: %#v", slots[0]) + } + if gotCode, _ := slotError(t, slots[1]); gotCode != string(ErrorCodeSerializationError) { + t.Errorf("slot 1 code = %q, want %q", gotCode, string(ErrorCodeSerializationError)) + } + if len(ec.ToolCalls) != 2 { + t.Errorf("recorded %d tool calls, want 2 — a dispatched element always records one", len(ec.ToolCalls)) + } + if ec.ToolCalls[1].Success { + t.Error("the unserializable call must be recorded as a failure") + } +} + +// TestCallToolsMalformedCallsAreWholeCallErrors (US2 / FR-012): a malformed +// batch returns ONE envelope naming the first offending element, dispatches +// nothing, and never throws. +func TestCallToolsMalformedCallsAreWholeCallErrors(t *testing.T) { + tests := []struct { + name string + code string + wantMessage string + }{ + {name: "no arguments", code: `call_tools()`, wantMessage: "requires 1 argument"}, + {name: "requests is not an array", code: `call_tools({server: "s", tool: "t"})`, wantMessage: "must be an array"}, + {name: "requests is a string", code: `call_tools("s")`, wantMessage: "must be an array"}, + {name: "element is not an object", code: `call_tools([{server: "s", tool: "t"}, 42])`, wantMessage: "element 1"}, + {name: "sparse hole", code: `call_tools([{server: "s", tool: "t"}, , {server: "s", tool: "u"}])`, wantMessage: "element 1"}, + {name: "missing server", code: `call_tools([{tool: "t"}])`, wantMessage: "element 0: server"}, + {name: "empty server", code: `call_tools([{server: "", tool: "t"}])`, wantMessage: "element 0: server"}, + {name: "missing tool", code: `call_tools([{server: "s"}])`, wantMessage: "element 0: tool"}, + {name: "args is not an object", code: `call_tools([{server: "s", tool: "t", args: "x"}])`, wantMessage: "element 0: args"}, + {name: "args is null", code: `call_tools([{server: "s", tool: "t", args: null}])`, wantMessage: "element 0: args"}, + {name: "first offending element is named", code: `call_tools([{server: "s", tool: "t"}, {server: "s"}, 7])`, wantMessage: "element 1"}, + {name: "options is not an object", code: `call_tools([{server: "s", tool: "t"}], 4)`, wantMessage: "options must be an object"}, + {name: "fractional max_parallel", code: `call_tools([{server: "s", tool: "t"}], {max_parallel: 2.5})`, wantMessage: "max_parallel must be an integer"}, + {name: "non-numeric max_parallel", code: `call_tools([{server: "s", tool: "t"}], {max_parallel: "4"})`, wantMessage: "max_parallel must be an integer"}, + {name: "max_parallel below range", code: `call_tools([{server: "s", tool: "t"}], {max_parallel: 0})`, wantMessage: "between 1 and 32"}, + {name: "max_parallel above range", code: `call_tools([{server: "s", tool: "t"}], {max_parallel: 33})`, wantMessage: "between 1 and 32"}, + { + name: "batch above the cap", + code: `var reqs = []; for (var i = 0; i < 101; i++) { reqs.push({server: "s", tool: "t"}); } call_tools(reqs)`, + wantMessage: "maximum of 100", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + stub := newBatchStub() + result, ec := execute(context.Background(), stub, tc.code, ExecutionOptions{TimeoutMs: 10000}) + if !result.Ok { + t.Fatalf("a malformed call_tools must return an envelope, not throw: %v", result.Error) + } + envelope, ok := result.Value.(map[string]interface{}) + if !ok { + t.Fatalf("expected a single envelope, got %T: %#v", result.Value, result.Value) + } + gotCode, gotMessage := envelopeError(t, envelope) + if gotCode != string(ErrorCodeInvalidArgs) { + t.Errorf("code = %q, want %q", gotCode, string(ErrorCodeInvalidArgs)) + } + if !strings.Contains(gotMessage, tc.wantMessage) { + t.Errorf("message = %q, want it to mention %q", gotMessage, tc.wantMessage) + } + if dispatched, _, _ := stub.stats(); dispatched != 0 { + t.Errorf("dispatched %d calls for a malformed batch", dispatched) + } + if len(ec.ToolCalls) != 0 { + t.Errorf("recorded %d tool calls for a malformed batch", len(ec.ToolCalls)) + } + }) + } +} + +// TestCallToolsOmittedArgsDefaultToEmptyObject: args is optional; an element +// without it dispatches with {} rather than failing. +func TestCallToolsOmittedArgsDefaultToEmptyObject(t *testing.T) { + stub := newBatchStub() + + slots, _ := batchSlots(t, stub, `call_tools([{server: "s", tool: "t"}])`, ExecutionOptions{TimeoutMs: 10000}) + + if len(slots) != 1 { + t.Fatalf("got %d slots, want 1", len(slots)) + } + if ok, _ := slots[0].(map[string]interface{})["ok"].(bool); !ok { + t.Fatalf("slot 0 failed: %#v", slots[0]) + } + + stub.mu.Lock() + defer stub.mu.Unlock() + args, seen := stub.seenArgs["t"] + if !seen { + t.Fatal("the element was never dispatched") + } + if args == nil || len(args) != 0 { + t.Errorf("dispatched args = %#v, want an empty object", args) + } +} + +// TestCallToolsHonorsMaxParallel (US3): concurrency never exceeds the +// effective bound, and the whole batch still completes. +func TestCallToolsHonorsMaxParallel(t *testing.T) { + const latency = 50 * time.Millisecond + + tests := []struct { + name string + elements int + optsMax int + code string + wantBound int + }{ + { + name: "per-batch override bounds the pool", + elements: 10, + code: `call_tools(reqs, {max_parallel: 3})`, + wantBound: 3, + }, + { + name: "per-batch override beats the configured default", + elements: 10, + optsMax: 8, + code: `call_tools(reqs, {max_parallel: 2})`, + wantBound: 2, + }, + { + name: "the configured default governs without an override", + elements: 10, + optsMax: 3, + code: `call_tools(reqs)`, + wantBound: 3, + }, + { + name: "the built-in default governs when nothing is configured", + elements: 16, + code: `call_tools(reqs)`, + wantBound: 8, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + stub := newBatchStub() + stub.latency = latency + + code := fmt.Sprintf(` + var reqs = []; + for (var i = 0; i < %d; i++) { reqs.push({server: "s", tool: "t" + i, args: {}}); } + %s + `, tc.elements, tc.code) + slots, _ := batchSlots(t, stub, code, ExecutionOptions{MaxParallel: tc.optsMax, TimeoutMs: 30000}) + + if len(slots) != tc.elements { + t.Fatalf("got %d slots, want %d", len(slots), tc.elements) + } + for i, slot := range slots { + if ok, _ := slot.(map[string]interface{})["ok"].(bool); !ok { + t.Fatalf("slot %d failed: %#v", i, slot) + } + } + + dispatched, highWater, _ := stub.stats() + if dispatched != tc.elements { + t.Errorf("dispatched %d calls, want %d", dispatched, tc.elements) + } + if highWater > tc.wantBound { + t.Errorf("high-water concurrency = %d, must not exceed %d", highWater, tc.wantBound) + } + if highWater != tc.wantBound { + t.Errorf("high-water concurrency = %d, want %d — the bound must be used, not undershot", highWater, tc.wantBound) + } + }) + } +} + +// TestBatchCancellationStillRecordsEveryElement (US3 / FR-007): when the +// execution context is cancelled mid-batch the workers stop dispatching, the +// join still completes, and every element the batch accepted still gets one +// slot and exactly one record — reservations and records can never diverge. +func TestBatchCancellationStillRecordsEveryElement(t *testing.T) { + t.Run("cancelled while in flight", func(t *testing.T) { + stub := newBatchStub() + stub.blockOnCtx = true + + ctx, cancel := context.WithCancel(context.Background()) + ec := newExecutionContext(stub, ExecutionOptions{}) + ec.ctx = ctx + + requests := make([]batchRequest, 6) + for i := range requests { + requests[i] = batchRequest{server: "s", tool: fmt.Sprintf("t%d", i), args: map[string]interface{}{}} + } + + go func() { + time.Sleep(20 * time.Millisecond) + cancel() + }() + + slots := ec.runBatch(requests, 3) + + if len(slots) != len(requests) { + t.Fatalf("got %d slots, want %d", len(slots), len(requests)) + } + for i, slot := range slots { + if slot == nil { + t.Fatalf("slot %d is empty — runBatch returned before its worker finished", i) + } + if ok, _ := slot.(map[string]interface{})["ok"].(bool); ok { + t.Errorf("slot %d succeeded although the execution was cancelled: %#v", i, slot) + } + } + if len(ec.ToolCalls) != len(requests) { + t.Errorf("recorded %d tool calls, want %d — one per accepted element, cancellation included", + len(ec.ToolCalls), len(requests)) + } + }) + + t.Run("cancelled before dispatch", func(t *testing.T) { + stub := newBatchStub() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + ec := newExecutionContext(stub, ExecutionOptions{}) + ec.ctx = ctx + + requests := []batchRequest{ + {server: "s", tool: "a", args: map[string]interface{}{}}, + {server: "s", tool: "b", args: map[string]interface{}{}}, + } + slots := ec.runBatch(requests, 2) + + if len(slots) != 2 { + t.Fatalf("got %d slots, want 2", len(slots)) + } + for i := range slots { + if gotCode, _ := slotError(t, slots[i]); gotCode != string(ErrorCodeUpstreamError) { + t.Errorf("slot %d code = %q, want %q", i, gotCode, string(ErrorCodeUpstreamError)) + } + } + if dispatched, _, _ := stub.stats(); dispatched != 0 { + t.Errorf("dispatched %d calls under a cancelled context", dispatched) + } + if len(ec.ToolCalls) != 2 { + t.Errorf("recorded %d tool calls, want 2", len(ec.ToolCalls)) + } + }) +} + +// TestExecutionTimeoutCancelsBatchWorkers (US3 / FR-007): a batch that outlives +// the execution timeout ends the execution with TIMEOUT, and every in-flight +// upstream call is cancelled rather than orphaned. +func TestExecutionTimeoutCancelsBatchWorkers(t *testing.T) { + stub := newBatchStub() + stub.blockOnCtx = true + + code := `call_tools([ + {server: "s", tool: "a"}, + {server: "s", tool: "b"}, + {server: "s", tool: "c"} + ])` + + result := Execute(context.Background(), stub, code, ExecutionOptions{TimeoutMs: 150}) + if result.Ok { + t.Fatalf("expected the execution to time out, got %#v", result.Value) + } + if result.Error.Code != ErrorCodeTimeout { + t.Fatalf("error code = %s, want %s", result.Error.Code, ErrorCodeTimeout) + } + + deadline := time.Now().Add(5 * time.Second) + for { + dispatched, _, cancelled := stub.stats() + if cancelled == 3 && dispatched == 3 { + break + } + if time.Now().After(deadline) { + t.Fatalf("only %d of %d in-flight calls were cancelled — workers must honor the execution context", + cancelled, dispatched) + } + time.Sleep(10 * time.Millisecond) + } +} + +// TestBatchWorkersDispatchUnderTheExecutionContext: the context a worker hands +// to the ToolCaller is the execution's, not a fresh background one — that is +// what makes upstream cancellation reach the server seam. +func TestBatchWorkersDispatchUnderTheExecutionContext(t *testing.T) { + stub := newBatchStub() + + _, _ = batchSlots(t, stub, `call_tools([{server: "s", tool: "t"}])`, ExecutionOptions{TimeoutMs: 5000}) + + stub.mu.Lock() + defer stub.mu.Unlock() + if len(stub.seenCtxs) != 1 { + t.Fatalf("captured %d contexts, want 1", len(stub.seenCtxs)) + } + if _, hasDeadline := stub.seenCtxs[0].Deadline(); !hasDeadline { + t.Error("batch workers must dispatch under the execution's timeout context") + } +} + +// TestQuickstartExample runs the documented quickstart script end to end, so +// the published example cannot drift from the implementation. +func TestQuickstartExample(t *testing.T) { + stub := newBatchStub() + stub.defaultFunc = func(_, tool string) interface{} { + return map[string]interface{}{ + "content": []interface{}{map[string]interface{}{"type": "text", "text": `{"title":"` + tool + `"}`}}, + } + } + + code := ` + var prs = call_tools( + [1, 2, 3, 4, 5].map(function (n) { + return {server: "github", tool: "get_pull_request", + args: {owner: "acme", repo: "api", pullNumber: n}}; + }), + {max_parallel: 5} + ); + + var titles = prs.map(function (r) { + if (!r.ok) { return "ERR: " + r.error.code; } + return JSON.parse(r.result.content[0].text).title; + }); + ({titles: titles}) + ` + result := Execute(context.Background(), stub, code, ExecutionOptions{TimeoutMs: 10000}) + if !result.Ok { + t.Fatalf("quickstart script failed: %v", result.Error) + } + values, ok := result.Value.(map[string]interface{}) + if !ok { + t.Fatalf("expected an object, got %T", result.Value) + } + titles, ok := values["titles"].([]interface{}) + if !ok { + t.Fatalf("titles is %T, want an array", values["titles"]) + } + if len(titles) != 5 { + t.Fatalf("got %d titles, want 5", len(titles)) + } + for i, title := range titles { + if title != "get_pull_request" { + t.Errorf("title %d = %v", i, title) + } + } +} diff --git a/internal/jsruntime/errors.go b/internal/jsruntime/errors.go index fe6e34be..b082d3ad 100644 --- a/internal/jsruntime/errors.go +++ b/internal/jsruntime/errors.go @@ -29,6 +29,20 @@ const ( // ErrorCodeInvalidLanguage indicates an unsupported language was specified ErrorCodeInvalidLanguage ErrorCode = "INVALID_LANGUAGE" + + // ErrorCodeInvalidArgs indicates a host function was called with arguments + // it cannot interpret (wrong arity, wrong types, malformed batch element) + ErrorCodeInvalidArgs ErrorCode = "INVALID_ARGS" + + // ErrorCodeAccessDenied indicates the agent token cannot reach the server + ErrorCodeAccessDenied ErrorCode = "ACCESS_DENIED" + + // ErrorCodePermissionDenied indicates the agent token lacks the permission + // tier the requested tool needs + ErrorCodePermissionDenied ErrorCode = "PERMISSION_DENIED" + + // ErrorCodeUpstreamError indicates the upstream tool call itself failed + ErrorCodeUpstreamError ErrorCode = "UPSTREAM_ERROR" ) // JsError represents a JavaScript execution error with message, stack trace, and error code diff --git a/internal/jsruntime/runtime.go b/internal/jsruntime/runtime.go index 2b1b3638..0099c293 100644 --- a/internal/jsruntime/runtime.go +++ b/internal/jsruntime/runtime.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "math" + "sync" "time" "github.com/dop251/goja" @@ -18,6 +20,7 @@ type ExecutionOptions struct { AllowedServers []string // Whitelist of allowed server names (empty = all allowed, unless RestrictToAllowed) ExecutionID string // Unique execution ID for logging (auto-generated if empty) Language string // Source language: "javascript" (default) or "typescript" + MaxParallel int // Default concurrency for call_tools() batches (0 = built-in default; per-batch options override this) // RestrictToAllowed enforces AllowedServers even when it is empty. Set by the // Spec 057 profile path: an active profile with an empty effective server set @@ -89,9 +92,15 @@ type ExecutionContext struct { ErrorDetails *JsError toolCaller ToolCaller maxToolCalls int + maxParallel int // configured default concurrency for call_tools() batches allowedServerMap map[string]bool restrictToAllowed bool // enforce allowedServerMap even when empty (Spec 057 deny-all profile) + // ctx is the execution's timeout context, wired by Execute. Batch workers + // dispatch under it so they are cancelled with the execution instead of + // outliving it; the lone call_tool() path keeps context.Background(). + ctx context.Context + // Auth enforcement (Spec 031) authInfo *AuthInfo toolAnnotationFunc ToolAnnotationLookup @@ -110,48 +119,64 @@ type ToolCallRecord struct { ErrorDetail interface{} `json:"error_details,omitempty"` } +// newExecutionContext builds the per-execution state Execute drives and the +// host functions enforce against. +func newExecutionContext(caller ToolCaller, opts ExecutionOptions) *ExecutionContext { + execCtx := &ExecutionContext{ + ExecutionID: opts.ExecutionID, + StartTime: time.Now(), + Status: "running", + ToolCalls: make([]ToolCallRecord, 0), + toolCaller: caller, + maxToolCalls: opts.MaxToolCalls, + maxParallel: opts.MaxParallel, + allowedServerMap: make(map[string]bool), + restrictToAllowed: opts.RestrictToAllowed, + authInfo: opts.AuthContext, + toolAnnotationFunc: opts.ToolAnnotationFunc, + maxPermissionLevel: "", + } + + // Build allowed server map for fast lookup + for _, serverName := range opts.AllowedServers { + execCtx.allowedServerMap[serverName] = true + } + + return execCtx +} + // Execute runs JavaScript or TypeScript code in a sandboxed environment with tool call capabilities. // When opts.Language is "typescript", the code is transpiled to JavaScript before execution. func Execute(ctx context.Context, caller ToolCaller, code string, opts ExecutionOptions) *Result { + result, _ := execute(ctx, caller, code, opts) + return result +} + +// execute is Execute plus the execution context it ran, which tests inspect for +// state the Result does not carry (recorded tool calls, the worker context). +func execute(ctx context.Context, caller ToolCaller, code string, opts ExecutionOptions) (*Result, *ExecutionContext) { // Generate execution ID if not provided if opts.ExecutionID == "" { opts.ExecutionID = uuid.New().String() } + // Create execution context + execCtx := newExecutionContext(caller, opts) + // Validate language parameter if langErr := ValidateLanguage(opts.Language); langErr != nil { - return NewErrorResult(langErr) + return NewErrorResult(langErr), execCtx } // Transpile TypeScript to JavaScript if needed if opts.Language == "typescript" { transpiled, transpileErr := TranspileTypeScript(code) if transpileErr != nil { - return NewErrorResult(transpileErr) + return NewErrorResult(transpileErr), execCtx } code = transpiled } - // Create execution context - execCtx := &ExecutionContext{ - ExecutionID: opts.ExecutionID, - StartTime: time.Now(), - Status: "running", - ToolCalls: make([]ToolCallRecord, 0), - toolCaller: caller, - maxToolCalls: opts.MaxToolCalls, - allowedServerMap: make(map[string]bool), - restrictToAllowed: opts.RestrictToAllowed, - authInfo: opts.AuthContext, - toolAnnotationFunc: opts.ToolAnnotationFunc, - maxPermissionLevel: "", - } - - // Build allowed server map for fast lookup - for _, serverName := range opts.AllowedServers { - execCtx.allowedServerMap[serverName] = true - } - // Initialize Goja VM vm := goja.New() @@ -163,13 +188,19 @@ func Execute(ctx context.Context, caller ToolCaller, code string, opts Execution opts.Input = make(map[string]interface{}) } if err := vm.Set("input", opts.Input); err != nil { - return NewErrorResult(NewJsError(ErrorCodeRuntimeError, fmt.Sprintf("failed to set input: %v", err))) + return NewErrorResult(NewJsError(ErrorCodeRuntimeError, fmt.Sprintf("failed to set input: %v", err))), execCtx } // Bind call_tool function callToolFunc := execCtx.makeCallToolFunction(vm) if err := vm.Set("call_tool", callToolFunc); err != nil { - return NewErrorResult(NewJsError(ErrorCodeRuntimeError, fmt.Sprintf("failed to set call_tool: %v", err))) + return NewErrorResult(NewJsError(ErrorCodeRuntimeError, fmt.Sprintf("failed to set call_tool: %v", err))), execCtx + } + + // Bind call_tools function (batched fan-out, Spec 096) + callToolsFunc := execCtx.makeCallToolsFunction(vm) + if err := vm.Set("call_tools", callToolsFunc); err != nil { + return NewErrorResult(NewJsError(ErrorCodeRuntimeError, fmt.Sprintf("failed to set call_tools: %v", err))), execCtx } // Set up timeout enforcement @@ -181,6 +212,10 @@ func Execute(ctx context.Context, caller ToolCaller, code string, opts Execution timeoutCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMs)*time.Millisecond) defer cancel() + // Batch workers dispatch under the execution's timeout context. Assigned + // before the script goroutine starts, so nothing reads it concurrently. + execCtx.ctx = timeoutCtx + // Run JavaScript with timeout enforcement resultChan := make(chan *Result, 1) go func() { @@ -199,16 +234,26 @@ func Execute(ctx context.Context, caller ToolCaller, code string, opts Execution execCtx.Status = "error" execCtx.ErrorDetails = result.Error } - return result + return result, execCtx case <-timeoutCtx.Done(): // Timeout occurred endTime := time.Now() execCtx.EndTime = &endTime execCtx.Status = "timeout" - return NewErrorResult(NewJsError(ErrorCodeTimeout, "JavaScript execution timed out")) + return NewErrorResult(NewJsError(ErrorCodeTimeout, "JavaScript execution timed out")), execCtx } } +// executionCtx returns the context batch workers dispatch under. An +// ExecutionContext built outside Execute has none, so fall back to a +// background context rather than dispatching with a nil one. +func (ec *ExecutionContext) executionCtx() context.Context { + if ec.ctx != nil { + return ec.ctx + } + return context.Background() +} + // executeWithVM runs the JavaScript code in the given VM and returns the result func executeWithVM(vm *goja.Runtime, code string, execCtx *ExecutionContext) *Result { // Compile the code first to catch syntax errors @@ -268,18 +313,72 @@ func setupSandbox(vm *goja.Runtime) { // so we don't need to explicitly block those } +// errorEnvelope builds the {ok:false, error:{code, message}} value both +// call_tool() and call_tools() hand back to scripts. +func errorEnvelope(code ErrorCode, message string) map[string]interface{} { + return map[string]interface{}{ + "ok": false, + "error": map[string]interface{}{ + "code": string(code), + "message": message, + }, + } +} + +// successEnvelope builds the {ok:true, result} value both host functions hand +// back to scripts. +func successEnvelope(result interface{}) map[string]interface{} { + return map[string]interface{}{ + "ok": true, + "result": result, + } +} + +// checkDispatchGates runs the scope gates a tool call must pass before it may +// be dispatched — allow-list/profile, agent-token server scope, permission +// tier — and returns the error envelope of the first failure (nil when the +// call may proceed) plus the permission tier the call requires. +// +// The gates are pure: the budget check and the updateMaxPermissionLevel side +// effect stay with the callers, because the batch path accounts for both +// across a whole batch before dispatching any of it. +func (ec *ExecutionContext) checkDispatchGates(serverName, toolName string) (gateErr map[string]interface{}, requiredPerm string) { + // Check allowed servers. When restrictToAllowed is set (active Spec 057 + // profile), the map is enforced even when empty — an empty effective set + // means "deny everything". Otherwise an empty map means "no restriction". + if (ec.restrictToAllowed || len(ec.allowedServerMap) > 0) && !ec.allowedServerMap[serverName] { + return errorEnvelope(ErrorCodeServerNotAllowed, fmt.Sprintf("server not allowed: %s", serverName)), "" + } + + // Auth context enforcement (Spec 031) + if ec.authInfo == nil { + return nil, "" + } + + if !ec.authInfo.CanAccessServer(serverName) { + return errorEnvelope(ErrorCodeAccessDenied, fmt.Sprintf("token does not have access to server '%s'", serverName)), "" + } + + // Determine required permission via annotation lookup + requiredPerm = "read" // Default to read + if ec.toolAnnotationFunc != nil { + requiredPerm = ec.toolAnnotationFunc(serverName, toolName) + } + + if !ec.authInfo.HasPermission(requiredPerm) { + return errorEnvelope(ErrorCodePermissionDenied, + fmt.Sprintf("token does not have '%s' permission for tool '%s:%s'", requiredPerm, serverName, toolName)), "" + } + + return nil, requiredPerm +} + // makeCallToolFunction creates the call_tool() function bound to this execution context func (ec *ExecutionContext) makeCallToolFunction(vm *goja.Runtime) func(goja.FunctionCall) goja.Value { return func(call goja.FunctionCall) goja.Value { // Extract arguments: call_tool(serverName, toolName, args) if len(call.Arguments) < 3 { - return vm.ToValue(map[string]interface{}{ - "ok": false, - "error": map[string]interface{}{ - "code": "INVALID_ARGS", - "message": "call_tool requires 3 arguments: serverName, toolName, args", - }, - }) + return vm.ToValue(errorEnvelope(ErrorCodeInvalidArgs, "call_tool requires 3 arguments: serverName, toolName, args")) } serverName := call.Arguments[0].String() @@ -289,68 +388,18 @@ func (ec *ExecutionContext) makeCallToolFunction(vm *goja.Runtime) func(goja.Fun argsValue := call.Arguments[2].Export() args, ok := argsValue.(map[string]interface{}) if !ok { - return vm.ToValue(map[string]interface{}{ - "ok": false, - "error": map[string]interface{}{ - "code": "INVALID_ARGS", - "message": "args must be an object", - }, - }) + return vm.ToValue(errorEnvelope(ErrorCodeInvalidArgs, "args must be an object")) } // Check max_tool_calls limit if ec.maxToolCalls > 0 && len(ec.ToolCalls) >= ec.maxToolCalls { - return vm.ToValue(map[string]interface{}{ - "ok": false, - "error": map[string]interface{}{ - "code": string(ErrorCodeMaxToolCallsExceeded), - "message": fmt.Sprintf("exceeded max tool calls limit: %d", ec.maxToolCalls), - }, - }) - } - - // Check allowed servers. When restrictToAllowed is set (active Spec 057 - // profile), the map is enforced even when empty — an empty effective set - // means "deny everything". Otherwise an empty map means "no restriction". - if (ec.restrictToAllowed || len(ec.allowedServerMap) > 0) && !ec.allowedServerMap[serverName] { - return vm.ToValue(map[string]interface{}{ - "ok": false, - "error": map[string]interface{}{ - "code": string(ErrorCodeServerNotAllowed), - "message": fmt.Sprintf("server not allowed: %s", serverName), - }, - }) + return vm.ToValue(errorEnvelope(ErrorCodeMaxToolCallsExceeded, + fmt.Sprintf("exceeded max tool calls limit: %d", ec.maxToolCalls))) } - // Auth context enforcement (Spec 031) - if ec.authInfo != nil { - // Check server access - if !ec.authInfo.CanAccessServer(serverName) { - return vm.ToValue(map[string]interface{}{ - "ok": false, - "error": map[string]interface{}{ - "code": "ACCESS_DENIED", - "message": fmt.Sprintf("token does not have access to server '%s'", serverName), - }, - }) - } - - // Determine required permission via annotation lookup - requiredPerm := "read" // Default to read - if ec.toolAnnotationFunc != nil { - requiredPerm = ec.toolAnnotationFunc(serverName, toolName) - } - - if !ec.authInfo.HasPermission(requiredPerm) { - return vm.ToValue(map[string]interface{}{ - "ok": false, - "error": map[string]interface{}{ - "code": "PERMISSION_DENIED", - "message": fmt.Sprintf("token does not have '%s' permission for tool '%s:%s'", requiredPerm, serverName, toolName), - }, - }) - } - + if gateErr, requiredPerm := ec.checkDispatchGates(serverName, toolName); gateErr != nil { + return vm.ToValue(gateErr) + } else if requiredPerm != "" { // Track highest permission level ec.updateMaxPermissionLevel(requiredPerm) } @@ -376,13 +425,7 @@ func (ec *ExecutionContext) makeCallToolFunction(vm *goja.Runtime) func(goja.Fun record.ErrorDetail = err.Error() ec.ToolCalls = append(ec.ToolCalls, record) - return vm.ToValue(map[string]interface{}{ - "ok": false, - "error": map[string]interface{}{ - "code": "UPSTREAM_ERROR", - "message": err.Error(), - }, - }) + return vm.ToValue(errorEnvelope(ErrorCodeUpstreamError, err.Error())) } // Tool call succeeded @@ -396,25 +439,313 @@ func (ec *ExecutionContext) makeCallToolFunction(vm *goja.Runtime) func(goja.Fun record.ErrorDetail = nerr.Error() ec.ToolCalls = append(ec.ToolCalls, record) - return vm.ToValue(map[string]interface{}{ - "ok": false, - "error": map[string]interface{}{ - "code": string(ErrorCodeSerializationError), - "message": "tool result is not JSON-serializable: " + nerr.Error(), - }, - }) + return vm.ToValue(errorEnvelope(ErrorCodeSerializationError, + "tool result is not JSON-serializable: "+nerr.Error())) } record.Result = plain ec.ToolCalls = append(ec.ToolCalls, record) - return vm.ToValue(map[string]interface{}{ - "ok": true, - "result": plain, - }) + return vm.ToValue(successEnvelope(plain)) } } +// Limits for call_tools() batches (Spec 096). +const ( + // batchMaxRequests caps a single batch so one script cannot fan out + // without bound. + batchMaxRequests = 100 + // batchMinParallel / batchMaxParallel bound the effective worker count, + // whatever the config or the script asks for. + batchMinParallel = 1 + batchMaxParallel = 32 + // batchDefaultParallel applies when neither the config nor the batch + // specifies a concurrency. + batchDefaultParallel = 8 +) + +// batchRequest is one validated element of a call_tools() batch. +type batchRequest struct { + server string + tool string + args map[string]interface{} +} + +// makeCallToolsFunction creates the call_tools() function bound to this +// execution context. The closure runs on the script goroutine: it parses and +// gates every element there, dispatches the survivors through a bounded worker +// pool, and converts the assembled slots back into VM values only after the +// workers have joined — the VM is owned by this goroutine alone. +func (ec *ExecutionContext) makeCallToolsFunction(vm *goja.Runtime) func(goja.FunctionCall) goja.Value { + return func(call goja.FunctionCall) goja.Value { + requests, maxParallel, err := parseBatchCall(call) + if err != nil { + // A malformed batch is a single envelope, never a throw: scripts + // handle call_tools failures the same way they handle call_tool + // failures. + return vm.ToValue(errorEnvelope(ErrorCodeInvalidArgs, err.Error())) + } + + return vm.ToValue(ec.runBatch(requests, maxParallel)) + } +} + +// parseBatchCall validates the call_tools(requests, options) arguments, +// returning the requests in input order and the per-batch max_parallel +// override (0 when absent). Any problem invalidates the WHOLE call — nothing +// is dispatched and no budget is consumed — and names the first offending +// element. +func parseBatchCall(call goja.FunctionCall) (requests []batchRequest, maxParallel int, err error) { + if len(call.Arguments) < 1 { + return nil, 0, fmt.Errorf("call_tools requires 1 argument: requests (an array of {server, tool, args})") + } + + rawRequests, ok := call.Arguments[0].Export().([]interface{}) + if !ok { + return nil, 0, fmt.Errorf("call_tools: requests must be an array of {server, tool, args}") + } + if len(rawRequests) > batchMaxRequests { + return nil, 0, fmt.Errorf("call_tools: batch of %d exceeds the maximum of %d requests", len(rawRequests), batchMaxRequests) + } + + maxParallel, err = parseBatchOptions(call) + if err != nil { + return nil, 0, err + } + + requests = make([]batchRequest, 0, len(rawRequests)) + for i, raw := range rawRequests { + // A sparse array hole exports as nil, so holes fail this check too. + element, ok := raw.(map[string]interface{}) + if !ok { + return nil, 0, fmt.Errorf("call_tools: element %d: must be an object with server and tool", i) + } + + server, ok := element["server"].(string) + if !ok || server == "" { + return nil, 0, fmt.Errorf("call_tools: element %d: server must be a non-empty string", i) + } + tool, ok := element["tool"].(string) + if !ok || tool == "" { + return nil, 0, fmt.Errorf("call_tools: element %d: tool must be a non-empty string", i) + } + + args := map[string]interface{}{} + if rawArgs, present := element["args"]; present { + args, ok = rawArgs.(map[string]interface{}) + if !ok { + return nil, 0, fmt.Errorf("call_tools: element %d: args must be an object", i) + } + } + + requests = append(requests, batchRequest{server: server, tool: tool, args: args}) + } + + return requests, maxParallel, nil +} + +// parseBatchOptions reads the optional second argument of call_tools(). +// Returns 0 when no override was supplied. Unknown keys are ignored. +func parseBatchOptions(call goja.FunctionCall) (int, error) { + if len(call.Arguments) < 2 { + return 0, nil + } + + // Passing undefined/null for a trailing optional argument means "no + // options", the way JavaScript callers expect. + raw := call.Arguments[1].Export() + if raw == nil { + return 0, nil + } + + options, ok := raw.(map[string]interface{}) + if !ok { + return 0, fmt.Errorf("call_tools: options must be an object") + } + + value, present := options["max_parallel"] + if !present { + return 0, nil + } + + maxParallel, ok := batchOptionInt(value) + if !ok { + return 0, fmt.Errorf("call_tools: options.max_parallel must be an integer") + } + if maxParallel < batchMinParallel || maxParallel > batchMaxParallel { + return 0, fmt.Errorf("call_tools: options.max_parallel must be between %d and %d, got %d", + batchMinParallel, batchMaxParallel, maxParallel) + } + return maxParallel, nil +} + +// batchOptionInt accepts the numeric shapes goja exports: int64 for integral +// numbers, float64 otherwise. A fractional value is rejected rather than +// truncated, so 2.5 never silently becomes 2. +func batchOptionInt(value interface{}) (int, bool) { + switch v := value.(type) { + case int64: + return int(v), true + case int: + return v, true + case float64: + if v != math.Trunc(v) { + return 0, false + } + return int(v), true + default: + return 0, false + } +} + +// effectiveMaxParallel resolves the worker bound for one batch: +// per-batch override > ExecutionOptions.MaxParallel (the configured default) > +// built-in default, always clamped to the supported range. +func (ec *ExecutionContext) effectiveMaxParallel(override int) int { + value := batchDefaultParallel + switch { + case override > 0: + value = override + case ec.maxParallel > 0: + value = ec.maxParallel + } + + if value < batchMinParallel { + return batchMinParallel + } + if value > batchMaxParallel { + return batchMaxParallel + } + return value +} + +// runBatch enforces, dispatches and assembles one call_tools() batch. It runs +// on the script goroutine; only the dispatch itself is concurrent. +func (ec *ExecutionContext) runBatch(requests []batchRequest, maxParallelOverride int) []interface{} { + slots := make([]interface{}, len(requests)) + records := make([]ToolCallRecord, len(requests)) + perms := make([]string, len(requests)) + dispatch := make([]int, 0, len(requests)) + + // Pre-dispatch pass, in input order, with the same check order a lone + // call_tool() uses: budget first, then the scope gates. The budget of + // element k counts the calls already recorded plus the elements this batch + // has accepted so far — a script-local count, so it cannot race, and every + // accepted element is guaranteed exactly one record at the join. + for i, req := range requests { + if ec.maxToolCalls > 0 && len(ec.ToolCalls)+len(dispatch) >= ec.maxToolCalls { + slots[i] = errorEnvelope(ErrorCodeMaxToolCallsExceeded, + fmt.Sprintf("exceeded max tool calls limit: %d", ec.maxToolCalls)) + continue + } + + gateErr, requiredPerm := ec.checkDispatchGates(req.server, req.tool) + if gateErr != nil { + slots[i] = gateErr + continue + } + + perms[i] = requiredPerm + dispatch = append(dispatch, i) + } + + if len(dispatch) == 0 { + return slots + } + + ec.dispatchBatch(requests, dispatch, slots, records, ec.effectiveMaxParallel(maxParallelOverride)) + + // Execution state is script-goroutine-only, so the records the workers + // produced are folded in here, in input order, after the join. + for _, i := range dispatch { + ec.ToolCalls = append(ec.ToolCalls, records[i]) + if perms[i] != "" { + ec.updateMaxPermissionLevel(perms[i]) + } + } + + return slots +} + +// dispatchBatch runs the accepted elements through a bounded worker pool and +// returns once every worker has finished. Each worker writes only into the +// slot and record cells its own index owns, so no locking is needed and the +// WaitGroup publishes the writes to the script goroutine. +func (ec *ExecutionContext) dispatchBatch(requests []batchRequest, dispatch []int, slots []interface{}, records []ToolCallRecord, workers int) { + if workers > len(dispatch) { + workers = len(dispatch) + } + + // The queue is prefilled and closed before any worker starts: there is no + // producer to outlive the pool and nothing to deadlock on. + indices := make(chan int, len(dispatch)) + for _, i := range dispatch { + indices <- i + } + close(indices) + + ctx := ec.executionCtx() + caller := ec.toolCaller + + var wg sync.WaitGroup + for w := 0; w < workers; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := range indices { + slots[i], records[i] = dispatchBatchElement(ctx, caller, requests[i]) + } + }() + } + + // Unconditional join: the script goroutine never touches the cells while a + // worker is alive, cancellation included. + wg.Wait() +} + +// dispatchBatchElement performs one upstream call for a batch and returns the +// slot the script sees plus the record the execution keeps. It touches no +// execution state, so it is safe to run on a worker goroutine. +func dispatchBatchElement(ctx context.Context, caller ToolCaller, req batchRequest) (map[string]interface{}, ToolCallRecord) { + record := ToolCallRecord{ + ServerName: req.server, + ToolName: req.tool, + Arguments: req.args, + StartTime: time.Now(), + } + + // A cancelled execution still owes every accepted element a slot and a + // record, so the remaining queue is drained into cancellation errors + // instead of being dispatched. + if err := ctx.Err(); err != nil { + record.DurationMs = time.Since(record.StartTime).Milliseconds() + record.ErrorDetail = err.Error() + return errorEnvelope(ErrorCodeUpstreamError, + fmt.Sprintf("execution ended before the call was dispatched: %v", err)), record + } + + result, err := caller.CallTool(ctx, req.server, req.tool, req.args) + record.DurationMs = time.Since(record.StartTime).Milliseconds() + + if err != nil { + record.ErrorDetail = err.Error() + return errorEnvelope(ErrorCodeUpstreamError, err.Error()), record + } + + // Expose the result under its wire (JSON) shape rather than the live Go + // value, exactly as the lone call_tool() path does. + plain, nerr := normalizeToolResult(result) + if nerr != nil { + record.ErrorDetail = nerr.Error() + return errorEnvelope(ErrorCodeSerializationError, + "tool result is not JSON-serializable: "+nerr.Error()), record + } + + record.Success = true + record.Result = plain + return successEnvelope(plain), record +} + // normalizeToolResult converts an upstream tool result into its JSON wire shape // so scripts see the documented field names (content[0].text) instead of Go // struct fields, and custom MarshalJSON semantics are preserved. diff --git a/internal/runtime/config_hotreload.go b/internal/runtime/config_hotreload.go index 7d2348ec..460c9d5a 100644 --- a/internal/runtime/config_hotreload.go +++ b/internal/runtime/config_hotreload.go @@ -191,6 +191,20 @@ func DetectConfigChanges(oldCfg, newCfg *config.Config) *ConfigApplyResult { result.ChangedFields = append(result.ChangedFields, "server_concurrency_defaults") } + // Code execution settings (Spec 096 FR-004 — hot-reloadable). The + // code_execution handler resolves timeout / max_tool_calls / + // max_parallel from the LIVE snapshot (p.currentConfig()) on every + // execution, and the runtime pool is re-sized when it is rebuilt, so a + // lone edit here must be reported as a change instead of being swallowed + // as "no changes detected". EnableCodeExecution is deliberately absent: + // toggling it changes the registered tool set, which is a restart concern. + if oldCfg.CodeExecutionTimeoutMs != newCfg.CodeExecutionTimeoutMs || + oldCfg.CodeExecutionMaxToolCalls != newCfg.CodeExecutionMaxToolCalls || + oldCfg.CodeExecutionPoolSize != newCfg.CodeExecutionPoolSize || + oldCfg.CodeExecutionMaxParallel != newCfg.CodeExecutionMaxParallel { + result.ChangedFields = append(result.ChangedFields, "code_execution") + } + // Logging configuration (can be hot-reloaded) if !reflect.DeepEqual(oldCfg.Logging, newCfg.Logging) { result.ChangedFields = append(result.ChangedFields, "logging") diff --git a/internal/runtime/config_hotreload_test.go b/internal/runtime/config_hotreload_test.go index d1cfe50a..70024afb 100644 --- a/internal/runtime/config_hotreload_test.go +++ b/internal/runtime/config_hotreload_test.go @@ -683,3 +683,45 @@ func TestDetectConfigChanges_HTTPTimeouts(t *testing.T) { assert.NotContains(t, result.ChangedFields, "http_idle_timeout") }) } + +// TestDetectConfigChanges_CodeExecution (Spec 096 FR-004): the code-execution +// knobs are read per execution via the live config snapshot, so an edit that +// touches only one of them must be reported as a hot-reloadable change instead +// of being swallowed as "No configuration changes detected". +func TestDetectConfigChanges_CodeExecution(t *testing.T) { + mk := func(mutate func(*config.Config)) *config.Config { + cfg := &config.Config{ + Listen: "127.0.0.1:8080", DataDir: "/d", TLS: &config.TLSConfig{}, + CodeExecutionTimeoutMs: 120000, + CodeExecutionMaxToolCalls: 0, + CodeExecutionPoolSize: 10, + CodeExecutionMaxParallel: 8, + } + if mutate != nil { + mutate(cfg) + } + return cfg + } + + changes := map[string]func(*config.Config){ + "code_execution_max_parallel": func(c *config.Config) { c.CodeExecutionMaxParallel = 16 }, + "code_execution_timeout_ms": func(c *config.Config) { c.CodeExecutionTimeoutMs = 60000 }, + "code_execution_max_tool_calls": func(c *config.Config) { c.CodeExecutionMaxToolCalls = 5 }, + "code_execution_pool_size": func(c *config.Config) { c.CodeExecutionPoolSize = 20 }, + } + + for name, mutate := range changes { + t.Run(name, func(t *testing.T) { + result := DetectConfigChanges(mk(nil), mk(mutate)) + require.True(t, result.Success) + assert.Contains(t, result.ChangedFields, "code_execution") + assert.False(t, result.RequiresRestart, "code execution settings are hot-reloadable") + }) + } + + t.Run("unchanged settings are not reported", func(t *testing.T) { + result := DetectConfigChanges(mk(nil), mk(nil)) + require.True(t, result.Success) + assert.NotContains(t, result.ChangedFields, "code_execution") + }) +} diff --git a/internal/server/code_execution_concurrency_test.go b/internal/server/code_execution_concurrency_test.go new file mode 100644 index 00000000..a8572f88 --- /dev/null +++ b/internal/server/code_execution_concurrency_test.go @@ -0,0 +1,52 @@ +package server + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream" +) + +// TestUpstreamToolCallerIsConcurrencySafe (Spec 096): call_tools() dispatches +// its elements from a worker pool, so the adapter the sandbox calls is now +// entered concurrently. Every call must still produce exactly one record and +// the type must be race-clean. +// +// Per-server admission (Spec 093) is enforced deeper, inside +// managed.Client.CallTool, and is covered by that spec's tests — nothing here +// re-tests it. That the workers dispatch under the execution's context (rather +// than a fresh background one) is pinned in internal/jsruntime/batch_test.go, +// where a stub ToolCaller can capture what it was handed. +func TestUpstreamToolCallerIsConcurrencySafe(t *testing.T) { + const callers = 32 + + logger := zap.NewNop() + // No clients are registered, so every call takes the "server not found" + // branch: it exercises the recording path without needing a live upstream. + upstreamManager := upstream.NewManager(logger, &config.Config{}, nil, nil, nil) + + caller := &upstreamToolCaller{ + upstreamManager: upstreamManager, + logger: logger, + executionID: "exec-1", + } + + var wg sync.WaitGroup + for i := 0; i < callers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := caller.CallTool(context.Background(), "missing", "tool", map[string]interface{}{}) + assert.Error(t, err) + }() + } + wg.Wait() + + require.Len(t, caller.getToolCalls(), callers, "every concurrent dispatch must record exactly one call") +} diff --git a/internal/server/code_execution_options_test.go b/internal/server/code_execution_options_test.go index 7d43638f..be34b9f9 100644 --- a/internal/server/code_execution_options_test.go +++ b/internal/server/code_execution_options_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/zap" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" "github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi" "github.com/smart-mcp-proxy/mcpproxy-go/internal/jsruntime" ) @@ -193,27 +194,27 @@ func TestRESTCodeExecOmittedOptionsKeepConfigDefaults(t *testing.T) { func TestResolveCodeExecutionDefaults(t *testing.T) { t.Run("absent max_tool_calls takes the configured limit", func(t *testing.T) { opts := jsruntime.ExecutionOptions{MaxToolCalls: codeExecMaxToolCallsUnset} - resolveCodeExecutionDefaults(&opts, 120000, 5) + resolveCodeExecutionDefaults(&opts, 120000, 5, 0) assert.Equal(t, 5, opts.MaxToolCalls) }) t.Run("explicit zero survives as the unlimited override", func(t *testing.T) { opts := jsruntime.ExecutionOptions{MaxToolCalls: codeExecMaxToolCallsUnset} require.Empty(t, applyCodeExecutionOptions(map[string]interface{}{"max_tool_calls": 0}, &opts)) - resolveCodeExecutionDefaults(&opts, 120000, 5) + resolveCodeExecutionDefaults(&opts, 120000, 5, 0) assert.Equal(t, 0, opts.MaxToolCalls) }) t.Run("explicit positive value wins over config", func(t *testing.T) { opts := jsruntime.ExecutionOptions{MaxToolCalls: codeExecMaxToolCallsUnset} require.Empty(t, applyCodeExecutionOptions(map[string]interface{}{"max_tool_calls": 2}, &opts)) - resolveCodeExecutionDefaults(&opts, 120000, 5) + resolveCodeExecutionDefaults(&opts, 120000, 5, 0) assert.Equal(t, 2, opts.MaxToolCalls) }) t.Run("absent timeout takes the configured budget", func(t *testing.T) { opts := jsruntime.ExecutionOptions{MaxToolCalls: codeExecMaxToolCallsUnset} - resolveCodeExecutionDefaults(&opts, 300000, 0) + resolveCodeExecutionDefaults(&opts, 300000, 0, 0) assert.Equal(t, 300000, opts.TimeoutMs) assert.Equal(t, 0, opts.MaxToolCalls) }) @@ -241,3 +242,58 @@ func TestApplyCodeExecutionOptionsRejectsNonIntegers(t *testing.T) { }) } } + +// TestResolveCodeExecutionMaxParallel pins the batch concurrency default +// (Spec 096): an execution that carries no MaxParallel takes the configured +// code_execution_max_parallel, so a hot-reloaded value reaches the sandbox on +// the next execution. The per-batch call_tools({max_parallel}) override lives +// inside the sandbox and is not visible here. +func TestResolveCodeExecutionMaxParallel(t *testing.T) { + t.Run("unset takes the configured value", func(t *testing.T) { + opts := jsruntime.ExecutionOptions{MaxToolCalls: codeExecMaxToolCallsUnset} + resolveCodeExecutionDefaults(&opts, 120000, 0, 16) + assert.Equal(t, 16, opts.MaxParallel) + }) + + t.Run("zero config value leaves the sandbox default in place", func(t *testing.T) { + opts := jsruntime.ExecutionOptions{MaxToolCalls: codeExecMaxToolCallsUnset} + resolveCodeExecutionDefaults(&opts, 120000, 0, 0) + assert.Zero(t, opts.MaxParallel, "an unset config value must stay unset for the sandbox to default") + }) + + t.Run("already-resolved value is not overwritten", func(t *testing.T) { + opts := jsruntime.ExecutionOptions{MaxToolCalls: codeExecMaxToolCallsUnset, MaxParallel: 4} + resolveCodeExecutionDefaults(&opts, 120000, 0, 16) + assert.Equal(t, 4, opts.MaxParallel) + }) +} + +// TestCodeExecutionDescriptionsDocumentCallTools (Spec 096): both +// code_execution registrations — the default surface in registerTools and the +// routing-mode builder — must document call_tools() and its max_parallel +// override. The two used to carry duplicated prose that could drift; they now +// share one source, and this test pins both the wording and the sharing. +func TestCodeExecutionDescriptionsDocumentCallTools(t *testing.T) { + t.Run("the shared description documents the batch API", func(t *testing.T) { + assert.Contains(t, codeExecutionToolDescription, "call_tools(requests, options)") + assert.Contains(t, codeExecutionToolDescription, "max_parallel") + assert.Contains(t, codeExecutionCodeDescription, "call_tools") + assert.Contains(t, codeExecutionOptionsDescription, "max_parallel") + }) + + t.Run("the routing-mode tool carries the shared description", func(t *testing.T) { + p := &MCPProxyServer{config: &config.Config{EnableCodeExecution: true}} + tools := p.buildCodeExecutionTool() + require.Len(t, tools, 1) + + assert.Equal(t, codeExecutionToolDescription, tools[0].Tool.Description) + + options, ok := tools[0].Tool.InputSchema.Properties["options"].(map[string]interface{}) + require.True(t, ok, "code_execution has no options parameter") + assert.Equal(t, codeExecutionOptionsDescription, options["description"]) + + code, ok := tools[0].Tool.InputSchema.Properties["code"].(map[string]interface{}) + require.True(t, ok, "code_execution has no code parameter") + assert.Equal(t, codeExecutionCodeDescription, code["description"]) + }) +} diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 87cd4686..e2c0de6b 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -910,24 +910,24 @@ func (p *MCPProxyServer) registerTools(_ bool) { // code_execution - JavaScript/TypeScript code execution for multi-tool orchestration (feature-flagged) if p.config.EnableCodeExecution { codeExecutionTool := mcp.NewTool("code_execution", - mcp.WithDescription("Execute JavaScript or TypeScript code that orchestrates multiple upstream MCP tools in a single request. Use this when you need to combine results from 2+ tools, implement conditional logic, loops, or data transformations that would require multiple round-trips otherwise.\n\n**When to use**: Multi-step workflows with data transformation, conditional logic, error handling, or iterating over results.\n**When NOT to use**: Single tool calls (use call_tool directly), long-running operations (>2 minutes).\n\n**Available in code**:\n- `input` global: Your input data passed via the 'input' parameter\n- `call_tool(serverName, toolName, args)`: Call upstream tools (returns {ok, result} or {ok, error})\n- Modern JavaScript (ES2020+): arrow functions, const/let, template literals, destructuring, classes, for-of, optional chaining (?.), nullish coalescing (??), spread/rest, Promises, Symbols, Map/Set, Proxy/Reflect (no require(), filesystem, or network access)\n\n**TypeScript support**: Set `language: \"typescript\"` to write TypeScript code with type annotations, interfaces, enums, and generics. Types are automatically stripped before execution.\n\n**Important runtime rules**:\n- `call_tool` is strictly SYNCHRONOUS. Do not use `await`.\n- Upstream tools usually return an MCP content array. To parse JSON results: `const data = JSON.parse(res.result.content[0].text);`\n- The last evaluated expression in your script is automatically returned as the final output.\n\n**Security**: Sandboxed execution with timeout enforcement. Respects existing quarantine and server restrictions."), + mcp.WithDescription(codeExecutionToolDescription), mcp.WithTitleAnnotation("Code Execution"), mcp.WithDestructiveHintAnnotation(true), mcp.WithReadOnlyHintAnnotation(false), mcp.WithOpenWorldHintAnnotation(true), mcp.WithString("code", mcp.Required(), - mcp.Description("JavaScript or TypeScript source code (ES2020+) to execute. Supports modern syntax: arrow functions, const/let, template literals, destructuring, optional chaining, nullish coalescing. Use `input` to access input data and `call_tool(serverName, toolName, args)` to invoke upstream tools. call_tool is SYNCHRONOUS — do not use await. Return value is the last evaluated expression and must be JSON-serializable. Example: `const res = call_tool('github', 'get_user', {username: input.username}); const data = JSON.parse(res.result.content[0].text); ({user: data, timestamp: Date.now()})`"), + mcp.Description(codeExecutionCodeDescription), ), mcp.WithString("language", - mcp.Description("Source code language. When set to 'typescript', the code is automatically transpiled to JavaScript before execution. Type annotations are stripped, enums and namespaces are converted to JavaScript equivalents. Default: 'javascript'."), + mcp.Description(codeExecutionLanguageDescription), mcp.Enum("javascript", "typescript"), ), mcp.WithObject("input", - mcp.Description("Input data accessible as global `input` variable in code (default: {})"), + mcp.Description(codeExecutionInputDescription), ), mcp.WithObject("options", - mcp.Description("Execution options: timeout_ms (1-600000, default: 120000), max_tool_calls (>= 0, 0=unlimited), allowed_servers (array of server names, empty=all allowed)"), + mcp.Description(codeExecutionOptionsDescription), ), ) p.server.AddTool(codeExecutionTool, p.handleCodeExecution) diff --git a/internal/server/mcp_code_execution.go b/internal/server/mcp_code_execution.go index fb707d80..e73bfebc 100644 --- a/internal/server/mcp_code_execution.go +++ b/internal/server/mcp_code_execution.go @@ -21,6 +21,50 @@ import ( "go.uber.org/zap" ) +// The code_execution tool is registered on two surfaces — the default tool set +// (registerTools) and the routing-mode builder (buildCodeExecutionTool) — which +// must advertise exactly the same contract. They share these strings so the two +// descriptions cannot drift apart. +const ( + codeExecutionToolDescription = "Execute JavaScript or TypeScript code that orchestrates multiple upstream MCP tools in a single request. " + + "Use this when you need to combine results from 2+ tools, implement conditional logic, loops, or data transformations " + + "that would require multiple round-trips otherwise.\n\n" + + "**When to use**: Multi-step workflows with data transformation, conditional logic, error handling, or iterating over results.\n" + + "**When NOT to use**: Single tool calls (use call_tool directly), long-running operations (>2 minutes).\n\n" + + "**Available in code**:\n" + + "- `input` global: Your input data passed via the 'input' parameter\n" + + "- `call_tool(serverName, toolName, args)`: Call upstream tools (returns {ok, result} or {ok, error})\n" + + "- `call_tools(requests, options)`: Call INDEPENDENT tools in parallel. `requests` is an array (max 100) of " + + "{server, tool, args} objects; `options` is optional and accepts `max_parallel` (1-32, defaults to the configured " + + "code_execution_max_parallel). Returns one {ok, result} / {ok, error} slot per request, in input order, so one " + + "failing call never fails the others. Malformed arguments return a single {ok:false, error} envelope and dispatch nothing.\n" + + "- Modern JavaScript (ES2020+): arrow functions, const/let, template literals, destructuring, classes, for-of, " + + "optional chaining (?.), nullish coalescing (??), spread/rest, Promises, Symbols, Map/Set, Proxy/Reflect " + + "(no require(), filesystem, or network access)\n\n" + + "**TypeScript support**: Set `language: \"typescript\"` to write TypeScript code with type annotations, interfaces, enums, and generics. " + + "Types are automatically stripped before execution.\n\n" + + "**Important runtime rules**:\n" + + "- `call_tool` and `call_tools` are strictly SYNCHRONOUS. Do not use `await`.\n" + + "- Upstream tools usually return an MCP content array. To parse JSON results: `const data = JSON.parse(res.result.content[0].text);`\n" + + "- The last evaluated expression in your script is automatically returned as the final output.\n\n" + + "**Security**: Sandboxed execution with timeout enforcement. Respects existing quarantine and server restrictions." + + codeExecutionCodeDescription = "JavaScript or TypeScript source code (ES2020+) to execute. Supports modern syntax: arrow functions, const/let, template literals, destructuring, " + + "optional chaining, nullish coalescing. Use `input` to access input data, `call_tool(serverName, toolName, args)` to invoke one upstream tool and " + + "`call_tools([{server, tool, args}, ...], {max_parallel})` to invoke independent tools in parallel. " + + "Both are SYNCHRONOUS — do not use await. Return value is the last evaluated expression and must be JSON-serializable. " + + "Example: `const res = call_tool('github', 'get_user', {username: input.username}); const data = JSON.parse(res.result.content[0].text); ({user: data, timestamp: Date.now()})`" + + codeExecutionLanguageDescription = "Source code language. When set to 'typescript', the code is automatically transpiled to JavaScript before execution. " + + "Type annotations are stripped, enums and namespaces are converted to JavaScript equivalents. Default: 'javascript'." + + codeExecutionInputDescription = "Input data accessible as global `input` variable in code (default: {})" + + codeExecutionOptionsDescription = "Execution options: timeout_ms (1-600000, default: 120000), max_tool_calls (>= 0, 0=unlimited), " + + "allowed_servers (array of server names, empty=all allowed). Batch concurrency is not an execution option: " + + "call_tools() defaults to the configured code_execution_max_parallel and is overridden per batch with call_tools(requests, {max_parallel})." +) + // handleCodeExecution executes JavaScript code that orchestrates multiple upstream tools func (p *MCPProxyServer) handleCodeExecution(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { p.recordMCPSurface() @@ -60,7 +104,16 @@ func (p *MCPProxyServer) handleCodeExecution(ctx context.Context, request mcp.Ca } } - resolveCodeExecutionDefaults(&options, p.config.CodeExecutionTimeoutMs, p.config.CodeExecutionMaxToolCalls) + // Read the LIVE snapshot, not the construction-time one: a hot-reloaded + // code_execution_* value must reach the executions that start after it. + // Without a config at all, every knob resolves to its built-in default. + var configTimeoutMs, configMaxToolCalls, configMaxParallel int + if cfg := p.currentConfig(); cfg != nil { + configTimeoutMs = cfg.CodeExecutionTimeoutMs + configMaxToolCalls = cfg.CodeExecutionMaxToolCalls + configMaxParallel = cfg.CodeExecutionMaxParallel + } + resolveCodeExecutionDefaults(&options, configTimeoutMs, configMaxToolCalls, configMaxParallel) // Extract session information from context var sessionID, clientName, clientVersion string @@ -405,14 +458,19 @@ const codeExecMaxToolCallsUnset = -1 // resolveCodeExecutionDefaults fills config defaults for the options the // caller left unset. timeout_ms uses zero as its unset marker (0 is out of // range and rejected during parsing); max_tool_calls uses the sentinel so an -// explicit zero survives. -func resolveCodeExecutionDefaults(opts *jsruntime.ExecutionOptions, configTimeoutMs, configMaxToolCalls int) { +// explicit zero survives. max_parallel has no request-level option — it is +// the configured default for call_tools() batches, which a script overrides +// per batch inside the sandbox. +func resolveCodeExecutionDefaults(opts *jsruntime.ExecutionOptions, configTimeoutMs, configMaxToolCalls, configMaxParallel int) { if opts.TimeoutMs == 0 { opts.TimeoutMs = configTimeoutMs } if opts.MaxToolCalls == codeExecMaxToolCallsUnset { opts.MaxToolCalls = configMaxToolCalls } + if opts.MaxParallel == 0 { + opts.MaxParallel = configMaxParallel + } } func applyCodeExecutionOptions(optionsObj map[string]interface{}, opts *jsruntime.ExecutionOptions) string { diff --git a/internal/server/mcp_routing.go b/internal/server/mcp_routing.go index 20d56d61..b95d1413 100644 --- a/internal/server/mcp_routing.go +++ b/internal/server/mcp_routing.go @@ -504,45 +504,24 @@ func (p *MCPProxyServer) buildCodeExecutionTool() []mcpserver.ServerTool { } codeExecutionTool := mcp.NewTool("code_execution", - mcp.WithDescription("Execute JavaScript or TypeScript code that orchestrates multiple upstream MCP tools in a single request. "+ - "Use this when you need to combine results from 2+ tools, implement conditional logic, loops, or data transformations "+ - "that would require multiple round-trips otherwise.\n\n"+ - "**When to use**: Multi-step workflows with data transformation, conditional logic, error handling, or iterating over results.\n"+ - "**When NOT to use**: Single tool calls (use call_tool directly), long-running operations (>2 minutes).\n\n"+ - "**Available in code**:\n"+ - "- `input` global: Your input data passed via the 'input' parameter\n"+ - "- `call_tool(serverName, toolName, args)`: Call upstream tools (returns {ok, result} or {ok, error})\n"+ - "- Modern JavaScript (ES2020+): arrow functions, const/let, template literals, destructuring, classes, for-of, "+ - "optional chaining (?.), nullish coalescing (??), spread/rest, Promises, Symbols, Map/Set, Proxy/Reflect "+ - "(no require(), filesystem, or network access)\n\n"+ - "**TypeScript support**: Set `language: \"typescript\"` to write TypeScript code with type annotations, interfaces, enums, and generics. "+ - "Types are automatically stripped before execution.\n\n"+ - "**Important runtime rules**:\n"+ - "- `call_tool` is strictly SYNCHRONOUS. Do not use `await`.\n"+ - "- Upstream tools usually return an MCP content array. To parse JSON results: `const data = JSON.parse(res.result.content[0].text);`\n"+ - "- The last evaluated expression in your script is automatically returned as the final output.\n\n"+ - "**Security**: Sandboxed execution with timeout enforcement. Respects existing quarantine and server restrictions."), + mcp.WithDescription(codeExecutionToolDescription), mcp.WithTitleAnnotation("Code Execution"), mcp.WithDestructiveHintAnnotation(true), mcp.WithReadOnlyHintAnnotation(false), mcp.WithOpenWorldHintAnnotation(true), mcp.WithString("code", mcp.Required(), - mcp.Description("JavaScript or TypeScript source code (ES2020+) to execute. Supports modern syntax: arrow functions, const/let, template literals, destructuring, "+ - "optional chaining, nullish coalescing. Use `input` to access input data and `call_tool(serverName, toolName, args)` to invoke upstream tools. "+ - "call_tool is SYNCHRONOUS — do not use await. Return value is the last evaluated expression and must be JSON-serializable. "+ - "Example: `const res = call_tool('github', 'get_user', {username: input.username}); const data = JSON.parse(res.result.content[0].text); ({user: data, timestamp: Date.now()})`"), + mcp.Description(codeExecutionCodeDescription), ), mcp.WithString("language", - mcp.Description("Source code language. When set to 'typescript', the code is automatically transpiled to JavaScript before execution. "+ - "Type annotations are stripped, enums and namespaces are converted to JavaScript equivalents. Default: 'javascript'."), + mcp.Description(codeExecutionLanguageDescription), mcp.Enum("javascript", "typescript"), ), mcp.WithObject("input", - mcp.Description("Input data accessible as global `input` variable in code (default: {})"), + mcp.Description(codeExecutionInputDescription), ), mcp.WithObject("options", - mcp.Description("Execution options: timeout_ms (1-600000, default: 120000), max_tool_calls (>= 0, 0=unlimited), allowed_servers (array of server names, empty=all allowed)"), + mcp.Description(codeExecutionOptionsDescription), ), ) return []mcpserver.ServerTool{{ diff --git a/oas/docs.go b/oas/docs.go index b3fb7869..a905a89b 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -6,7 +6,7 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"config.ConcurrencyDefaults":{"description":"ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server\ndefault set inherited by every server that does not override a setting.\nAbsent (the default) = no per-server limiting unless a server configures\nit explicitly. File/API-configured only — no env scheme (FR-022).","properties":{"max_concurrent_requests":{"type":"integer"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"}},"type":"object"},"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_max_size_mb":{"description":"Max total activity-log size in MB before pruning oldest (default: 256, 0=disabled)","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"allow_private_registry_fetch":{"description":"AllowPrivateRegistryFetch opts out of the registry SSRF guard (MCP-1076,\nCWE-918). By default (false) registry fetches refuse any host that is — or\nresolves to — a non-routable address (loopback, RFC1918/CGNAT private,\nlink-local incl. the 169.254.169.254 cloud-metadata endpoint), so a\nmalicious or typo'd registry source cannot turn the daemon into a\nrequest-forgery vector against internal services.\n\nThis opt-out is BLANKET (all-or-nothing): setting it true disables the\nguard for EVERY non-routable range at once — loopback, RFC1918/CGNAT\nprivate, link-local AND the 169.254.169.254 cloud-metadata endpoint. There\nis no way to allow only loopback; enabling it for a localhost dev registry\nalso re-opens the cloud-metadata SSRF vector. Set true ONLY when you\nintentionally run a trusted registry mirror on an internal/private address,\nideally on a host with no cloud-metadata exposure. The change takes effect\nonly on daemon (re)start or config reload.","type":"boolean"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"api_key":{"description":"Security settings","type":"string"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"disable_management":{"type":"boolean"},"docker_isolation":{"$ref":"#/components/schemas/config.DockerIsolationConfig"},"docker_recovery":{"$ref":"#/components/schemas/config.DockerRecoveryConfig"},"enable_code_execution":{"description":"Code execution settings","type":"boolean"},"enable_prompts":{"description":"Prompts settings","type":"boolean"},"enable_socket":{"description":"Enable Unix socket/named pipe for local IPC (default: true)","type":"boolean"},"enable_tray":{"description":"Deprecated: EnableTray is unused and has no runtime effect. Kept for backward compatibility.","type":"boolean"},"environment":{"$ref":"#/components/schemas/secureenv.EnvConfig"},"features":{"$ref":"#/components/schemas/config.FeatureFlags"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"http_idle_timeout":{"description":"HTTPIdleTimeout caps how long an idle keep-alive connection is kept open.\nUnset = 180s. \"0s\" removes the dedicated idle deadline, but net/http then\nfalls back to ReadTimeout — idle is fully unbounded only when\nhttp_read_timeout is also \"0s\". Requires a restart.","type":"string"},"http_read_timeout":{"description":"HTTPReadTimeout caps how long reading a whole request (headers + body)\nmay take. Unset = 120s; \"0s\" disables it. Requires a restart.","type":"string"},"http_write_timeout":{"description":"HTTPWriteTimeout caps how long producing a whole response may take on\nnon-streaming endpoints (REST, Web UI, health). Unset = 120s; \"0s\"\ndisables it globally. MCP and SSE /events routes are exempt by design.","type":"string"},"init_timeout":{"description":"InitTimeout is the global default deadline for an upstream's MCP\n` + "`" + `initialize` + "`" + ` handshake (MCP-3322 / GH #760). *Duration tri-state: nil =\ninherit the built-in 30s default; a positive value = that deadline. A\nper-server InitTimeout overrides this. Resolved by ResolveInitTimeout;\nvalidated to {0} ∪ [1s, 30m] in Validate(). Servers doing legitimate\nfirst-run warmup (cache/index build) before answering ` + "`" + `initialize` + "`" + ` can\nraise this so they are not killed mid-startup.","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_concurrent_requests":{"description":"Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL\nAGGREGATE limiter — one proxy-wide cap on concurrently running upstream\ntool calls, with its own bounded wait queue. Tri-state pointers: absent =\nthe limiter does not exist (default, zero behavior change); an explicit 0\nmax also disables it; positive = that cap. This scope is NEVER a\nper-server inheritance source — per-server values come from\nServerConcurrencyDefaults / the per-server overrides — but a server's\neffective concurrency is bounded by BOTH its own limiter and this one.\nResolved by ResolveGlobalConcurrency; hot-reloadable; overridable via\nMCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).","type":"integer"},"max_result_size_chars":{"description":"Advertised on every tool as ` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; raises Claude Code's inline-response ceiling from 50k to up to 500k chars. Set to 0 to disable.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of the\nsecret-bearing server fields — sensitive header values (Authorization,\nX-API-Key, Cookie, …), env-var secrets, and URL query credentials — in\nresponses from the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + `\nREST API, and the SSE event stream. It also lets URL secrets echoed\ninto last_error / health.detail through unscrubbed.\n\nDefault false — sensitive values are surfaced masked as\n` + "`" + `••••\u003clast2\u003e (\u003cN\u003e chars)` + "`" + ` (error strings use ` + "`" + `***REDACTED***` + "`" + `) so an\nMCP agent cannot read Bearer tokens / API keys / URL secrets out of\nanother upstream's config (PR #425, issue #872). ${env:…}/${keyring:…}\nreferences are labels, not secrets, and pass through unchanged.\n\nThe Web UI / macOS tray edit forms work without seeing the real\nvalues: PATCH /api/v1/servers/{id} deep-merges (omitted keys are\npreserved, see ` + "`" + `headers_remove` + "`" + ` / ` + "`" + `env_remove` + "`" + ` for explicit\ndeletes), so clients compute a diff and only send the keys that\nactually changed. Redacted-but-unchanged values never round-trip\n— the backend keeps the real string. Set this to true if a\ndownstream tool genuinely needs raw values in the response.","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"security":{"$ref":"#/components/schemas/config.SecurityConfig"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"server_concurrency_defaults":{"$ref":"#/components/schemas/config.ConcurrencyDefaults"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_discovery_interval":{"type":"string"},"tool_response_limit":{"type":"integer"},"tool_response_mode":{"description":"Tool response mode (Spec 085): how retrieve_tools serializes results.\nValid values: \"\" (= full), \"full\" (default: today's schema-bearing\nentries), \"compact\" (signature + first-sentence entries). Orthogonal to\nrouting_mode — routing_mode selects the tool SURFACE, this selects the\nSERIALIZATION within the retrieve_tools surface. Serialization-only: it\nnever affects the query, ranking, or result set. Hot-reloadable.","type":"string"},"tool_response_session_risk_warning":{"description":"ToolResponseSessionRiskWarning controls whether the prose ` + "`" + `warning` + "`" + ` field\nis included in the ` + "`" + `session_risk` + "`" + ` object returned by ` + "`" + `retrieve_tools` + "`" + `.\nThe structured fields (level, lethal_trifecta, has_open_world_tools, etc.)\nare always included. Default: false (quiet for LLM clients) — see issue #406.\nMost tools lack annotations, so the MCP-spec defaults treat them as fully\npermissive across all three risk axes, which makes the prose warning fire\non almost every call and wastes tokens.","type":"boolean"},"tools_limit":{"type":"integer"},"toon_min_savings_pct":{"description":"ToonMinSavingsPct is the minimum byte-savings percentage (validated\n1-90; 0/unset → 15) the complete TOON emission (marker + hint + body)\nmust achieve over the exact passthrough emission for adaptive mode to\nencode a block. Byte savings approximate token savings for the tabular\npayload class; the spec-083 profiler reports true token deltas.\nGlobal-only (no per-server override, FR-001).","type":"integer"},"toon_output":{"description":"ToonOutput selects the TOON encoding mode for call_tool_* result text\nblocks (spec 084): \"off\" (default — responses byte-identical to\npre-feature behavior), \"adaptive\" (encode only tabular-uniform payloads\nthat beat compact JSON by ToonMinSavingsPct), or \"always\"\n(benchmark/debug only — encodes every JSON-parseable block and can\nINCREASE token cost). Per-server override: ServerConfig.ToonOutput.\nResolved by ResolveToonOutput; hot-reloadable.","type":"string"},"top_k":{"description":"Deprecated: TopK is superseded by ToolsLimit and has no runtime effect. Kept for backward compatibility.","type":"integer"},"tray_endpoint":{"description":"Tray endpoint override (unix:// or npipe://)","type":"string"},"trusted_hosts":{"description":"TrustedHosts lists non-loopback Host header values accepted on loopback\nlisteners (GH #898). DNS-rebinding protection rejects requests whose Host\nheader is not a loopback address when mcpproxy listens on loopback; a\nreverse proxy (nginx → 127.0.0.1) forwarding the public domain in Host\ntrips it. Entries are hostnames, case-insensitive; an entry without a\nport matches any port, with a port it must match exactly; a leading dot\n(\".example.com\") is a subdomain wildcard. The single entry \"*\" disables\nHost and Origin validation entirely. The same list also validates the\nOrigin header when present (MCP spec DNS-rebinding defense). Empty\n(default) keeps full protection. Env override: MCPPROXY_TRUSTED_HOSTS\n(comma-separated).","items":{"type":"string"},"type":"array","uniqueItems":false},"update_check":{"$ref":"#/components/schemas/config.UpdateCheckConfig"}},"type":"object"},"config.CustomPattern":{"properties":{"category":{"description":"Category (defaults to \"custom\")","type":"string"},"keywords":{"description":"Keywords to match (mutually exclusive with Regex)","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Unique identifier for this pattern","type":"string"},"regex":{"description":"Regex pattern (mutually exclusive with Keywords)","type":"string"},"severity":{"description":"Risk level: critical, high, medium, low","type":"string"}},"type":"object"},"config.DeepScanConfig":{"description":"DeepScan is the opt-in \"deep scan\" layer (Spec 077 US3). It subsumes the\ndeprecated top-level scanner_fetch_package_source / scanner_disable_no_new_privileges\nkeys (migrated on load) and gates the heavy Docker-based scanners + source\nextraction. Disabled by default (FR-006): only the deterministic in-process\nbaseline scanner runs. A deep-scan failure NEVER changes the baseline verdict\n(FR-007/FR-008).","properties":{"disable_no_new_privileges":{"description":"DisableNoNewPrivileges, when true, omits the ` + "`" + `--security-opt\nno-new-privileges` + "`" + ` flag from scanner container runs (snap-docker/AppArmor\nescape hatch). Absorbs the deprecated top-level\nscanner_disable_no_new_privileges. Default false.","type":"boolean"},"enabled":{"description":"Enabled is the master opt-in for the heavy layer (FR-006). Default false.","type":"boolean"},"fetch_package_source":{"description":"FetchPackageSource controls whether the scanner fetches the PUBLISHED\nsource of package-runner servers (npx/uvx) — without executing it — when\nno local source is available. Absorbs the deprecated top-level\nscanner_fetch_package_source. Default (nil) is ENABLED within deep scan.","type":"boolean"},"scanners":{"description":"Scanners optionally restricts which deep scanners may run under the\numbrella (by scanner id). Empty ⇒ all enabled deep scanners are eligible.","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.DockerIsolationConfig":{"description":"Docker isolation settings","properties":{"cpu_limit":{"description":"CPU limit for containers","type":"string"},"default_images":{"additionalProperties":{"type":"string"},"description":"Map of runtime type to Docker image","type":"object"},"enable_cache_volume":{"description":"Mount shared cache volumes for faster restarts (default: true)","type":"boolean"},"enabled":{"description":"Global enable/disable for Docker isolation (legacy; superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments","items":{"type":"string"},"type":"array","uniqueItems":false},"log_driver":{"description":"Docker log driver (default: json-file)","type":"string"},"log_max_files":{"description":"Maximum number of log files (default: 3)","type":"string"},"log_max_size":{"description":"Maximum size of log files (default: 100m)","type":"string"},"memory_limit":{"description":"Memory limit for containers","type":"string"},"mode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"network_mode":{"description":"Docker network mode (default: bridge)","type":"string"},"registry":{"description":"Custom registry (defaults to docker.io)","type":"string"},"timeout":{"description":"Container startup timeout","type":"string"}},"type":"object"},"config.DockerRecoveryConfig":{"description":"Docker recovery settings","properties":{"enabled":{"description":"Enable Docker recovery monitoring (default: true)","type":"boolean"},"max_retries":{"description":"Maximum retry attempts (0 = unlimited)","type":"integer"},"notify_on_failure":{"description":"Show notification on recovery failure (default: true)","type":"boolean"},"notify_on_retry":{"description":"Show notification on each retry (default: false)","type":"boolean"},"notify_on_start":{"description":"Show notification when recovery starts (default: true)","type":"boolean"},"notify_on_success":{"description":"Show notification on successful recovery (default: true)","type":"boolean"},"persistent_state":{"description":"Save recovery state across restarts (default: true)","type":"boolean"}},"type":"object"},"config.FeatureFlags":{"description":"Deprecated: Features flags are unused and have no runtime effect. Kept for backward compatibility.","properties":{"enable_async_storage":{"type":"boolean"},"enable_caching":{"type":"boolean"},"enable_contract_tests":{"type":"boolean"},"enable_debug_logging":{"description":"Development features","type":"boolean"},"enable_docker_isolation":{"type":"boolean"},"enable_event_bus":{"type":"boolean"},"enable_health_checks":{"type":"boolean"},"enable_metrics":{"type":"boolean"},"enable_oauth":{"description":"Security features","type":"boolean"},"enable_observability":{"description":"Observability features","type":"boolean"},"enable_quarantine":{"type":"boolean"},"enable_runtime":{"description":"Runtime features","type":"boolean"},"enable_search":{"description":"Storage features","type":"boolean"},"enable_sse":{"type":"boolean"},"enable_tracing":{"type":"boolean"},"enable_tray":{"type":"boolean"},"enable_web_ui":{"description":"UI features","type":"boolean"}},"type":"object"},"config.IntentDeclarationConfig":{"description":"Intent declaration settings (Spec 018)","properties":{"strict_server_validation":{"description":"StrictServerValidation controls whether server annotation mismatches\ncause rejection (true) or just warnings (false).\nDefault: true (reject mismatches)","type":"boolean"}},"type":"object"},"config.IsolationConfig":{"description":"Per-server isolation settings","properties":{"enabled":{"description":"Enable Docker isolation for this server (nil = inherit global; legacy, superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments for this server","items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"description":"Custom Docker image (overrides default)","type":"string"},"log_driver":{"description":"Docker log driver override for this server","type":"string"},"log_max_files":{"description":"Maximum number of log files override","type":"string"},"log_max_size":{"description":"Maximum size of log files override","type":"string"},"mode":{"$ref":"#/components/schemas/config.IsolationMode"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"config.IsolationMode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"config.LogConfig":{"description":"Logging configuration","properties":{"compress":{"type":"boolean"},"enable_console":{"type":"boolean"},"enable_file":{"type":"boolean"},"filename":{"type":"string"},"json_format":{"type":"boolean"},"level":{"type":"string"},"log_dir":{"description":"Custom log directory","type":"string"},"max_age":{"description":"days","type":"integer"},"max_backups":{"description":"number of backup files","type":"integer"},"max_size":{"description":"MB","type":"integer"}},"type":"object"},"config.MetricsExporterConfig":{"description":"Metrics gates the Prometheus /metrics scrape endpoint (MCP-32). Disabled\nby default — operators opt in for k8s/enterprise deployments.","properties":{"enabled":{"description":"Enabled exposes /metrics on the existing HTTP listener when true.","type":"boolean"}},"type":"object"},"config.OAuthConfig":{"description":"OAuth configuration (keep even when empty to signal OAuth requirement)","properties":{"client_id":{"type":"string"},"client_secret":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters (e.g., RFC 8707 resource)","type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_uri":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ObservabilityConfig":{"description":"Observability settings (Spec 069): usage aggregate cache/persistence cadence.","properties":{"metrics":{"$ref":"#/components/schemas/config.MetricsExporterConfig"},"tracing":{"$ref":"#/components/schemas/config.TracingExporterConfig"},"usage_cache_ttl":{"description":"UsageCacheTTL bounds the freshness of the usage endpoint's read cache for\nwide windows (FR-005). Default 5s.","type":"string"},"usage_persist_interval":{"description":"UsagePersistInterval is how often the actor-owned usage aggregate snapshot\nis flushed to storage. Default 30s.","type":"string"}},"type":"object"},"config.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.ProfileConfig":{"properties":{"name":{"description":"URL slug, validated","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"deep_scan":{"$ref":"#/components/schemas/config.DeepScanConfig"},"integrity_check_interval":{"type":"string"},"integrity_check_on_restart":{"type":"boolean"},"runtime_read_only":{"type":"boolean"},"runtime_tmpfs_size":{"type":"string"},"scan_timeout_default":{"type":"string"},"scanner_disable_no_new_privileges":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.DisableNoNewPrivileges\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.IsDisableNoNewPrivileges. Cleared after migration.\n\nScannerDisableNoNewPrivileges, when true, omits the\n` + "`" + `--security-opt no-new-privileges` + "`" + ` flag from scanner container runs.\n\nBackground: snap-installed Docker on Ubuntu confines dockerd under the\n` + "`" + `snap.docker.dockerd` + "`" + ` AppArmor profile. When runc tries to transition\nthe container into the inner ` + "`" + `docker-default` + "`" + ` profile to exec the\nentrypoint, AppArmor refuses the transition because NO_NEW_PRIVS\nforbids privilege/profile changes on exec — the result is EPERM\n(\"operation not permitted\") and every scanner fails immediately.\n\nSet this to true ONLY on hosts hitting that incompatibility. Scanner\ncontainers still run with read-only rootfs, tmpfs /tmp, no-network by\ndefault, and read-only source mounts, so the marginal isolation loss\nis small. The preferred fix remains replacing snap docker with a\ndistro-packaged docker.","type":"boolean"},"scanner_fetch_package_source":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.FetchPackageSource\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.EffectiveFetchPackageSource. Cleared after migration.\n\nScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"},"tpa_bundle_path":{"description":"TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json\nthe offline TPA scanner runs (spec 086 FR-019: the signature-DB location\nMUST be configuration-driven, not hardcoded). Empty (the default) runs the\ncorpus embedded in this build.\n\nEnv override: MCPPROXY_TPA_BUNDLE_PATH. Hot-reloadable — the path is\nre-read on every config.reloaded event via\nscanner.Service.ApplySecurityConfig, so a corpus refresh needs no restart.\nA configured bundle that fails to read/parse/version-check/compile is\nREFUSED and the previously active corpus stays live (fail-closed, never\nfail-empty); the reason is logged and surfaced in the security overview's\nsignature_bundle.load_error.","type":"string"}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"init_timeout":{"description":"InitTimeout overrides the global init_timeout for this server's MCP\n` + "`" + `initialize` + "`" + ` handshake deadline (MCP-3322 / GH #760). *Duration tri-state:\nnil = inherit the global value (or 30s default), positive = that deadline.\nResolved by Config.ResolveInitTimeout; validated to {0} ∪ [1s, 30m]. Raise\nthis for upstreams that do legitimate first-run warmup (e.g. caching many\nchannels/users) before responding to ` + "`" + `initialize` + "`" + `.","type":"string"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"launcher_wait_timeout":{"description":"LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched\nHTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted\nwhen the server is configured with both Command and an HTTP/SSE URL — i.e.,\nmcpproxy starts the process AND connects via network. Stdio servers ignore\nthis field. Zero or unset → 30s default.","type":"string"},"max_concurrent_requests":{"description":"Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).\nTri-state per setting, exactly like HealthCheckInterval: absent = inherit\nthe per-server default set (server_concurrency_defaults), explicit 0 =\ndisable that setting for this server (0 max = no per-server limiter at\nall; 0 queue_size = no pending capacity, shed immediately at the cap),\npositive = override. The global aggregate limiter is never inherited from\nhere — it applies on top, so effective concurrency is min(per-server,\nglobal). Resolved by Config.ResolveServerConcurrency.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/config.OAuthConfig"},"protocol":{"description":"stdio, http, sse, streamable-http, auto","type":"string"},"quarantined":{"description":"Security quarantine status","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets a disconnected server","type":"boolean"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"toon_output":{"description":"ToonOutput overrides the global toon_output mode for this server's\ntools (spec 084, FR-001). Plain string, not a pointer: \"\"/absent =\ninherit the global value; \"off\"|\"adaptive\"|\"always\" = override (\"off\"\nis the explicit force-off). Resolved by Config.ResolveToonOutput.","type":"string"},"trust_mode":{"description":"TrustMode is the per-server trust tier: auto|scan|manual. Supersedes\nauto_approve_tool_changes (spec 086). An empty value is derived from the\nlegacy fields at load via normalizeServerQuarantineFlags; the single\nresolution point is EffectiveTrustMode(), which treats an empty or\nunrecognized value as manual (secure by default). Read via\nEffectiveTrustMode(), never the raw string.","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TelemetryConfig":{"description":"Telemetry settings (Spec 036)","properties":{"anonymous_id":{"description":"Auto-generated UUIDv4","type":"string"},"anonymous_id_created_at":{"description":"Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"},"last_reported_version":{"description":"Upgrade funnel","type":"string"},"last_startup_outcome":{"description":"success|port_conflict|db_locked|...","type":"string"},"notice_shown":{"description":"First-run notice flag","type":"boolean"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"config.TracingExporterConfig":{"description":"Tracing gates the OpenTelemetry OTLP trace exporter (MCP-32). Disabled by\ndefault.","properties":{"enabled":{"description":"Enabled turns on OTLP trace export for tool calls and upstream hops.","type":"boolean"},"endpoint":{"description":"Endpoint is the collector address as host:port (no scheme), e.g.\n\"localhost:4318\" for http or \"localhost:4317\" for grpc.","type":"string"},"protocol":{"description":"Protocol selects the OTLP transport: \"http\" or \"grpc\".","type":"string"},"sample_rate":{"description":"SampleRate is the head-based trace sampling ratio in [0,1]. Default 0.1.","type":"number"}},"type":"object"},"config.UpdateCheckConfig":{"description":"Update-check settings (Spec 079 FR-012): config-file control of the\nbackground upgrade-awareness checker (internal/updatecheck). nil =\nenabled on the stable channel (existing default behavior). The existing\nenvironment switches keep working and WIN over these keys (FR-014):\nMCPPROXY_DISABLE_AUTO_UPDATE=true force-disables even when\nenabled=true, and MCPPROXY_ALLOW_PRERELEASE_UPDATES=true force-selects\nthe rc channel even when channel=stable.","properties":{"channel":{"description":"Channel selects which releases are offered as updates: \"stable\"\n(default; prereleases never offered) or \"rc\" (prereleases included).\nEmpty resolves to stable. Validated in ValidateDetailed.\n\nNOTE: for a RELEASED build the running binary's own version is\nauthoritative and overrides this field — a stable build is never\noffered an RC (even with channel=rc), and an RC build always tracks the\nrc channel. This field only takes effect on dev/unstamped builds. See\ninternal/updatecheck.Checker.IncludePrereleases.","type":"string"},"enabled":{"description":"Enabled gates all update checking. Tri-state: nil/absent = enabled\n(default true, matching pre-079 behavior). When false, no network\ncheck is performed and no upgrade nudge appears on any surface\n(FR-015) — /api/v1/info omits the update object entirely.","type":"boolean"}},"type":"object"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\"","type":"string"}},"type":"object"},"connect.ConnectResult":{"description":"The full result; its action mirrors the top-level one","properties":{"action":{"description":"\"created\", \"updated\", \"already_exists\", \"removed\", \"not_found\"","type":"string"},"backup_path":{"type":"string"},"client":{"type":"string"},"config_path":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"arguments":{"description":"Tool call arguments","type":"object"},"detection_types":{"description":"List of detection types found","items":{"type":"string"},"type":"array","uniqueItems":false},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP transport session ID (regenerated on every reconnect)","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\", \"rejected\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"},"work_session_id":{"description":"Spec 082: one client, one project, across reconnects","type":"string"}},"type":"object"},"contracts.ActivitySource":{"description":"How activity was triggered: \"mcp\", \"cli\", \"api\"","type":"string","x-enum-varnames":["ActivitySourceMCP","ActivitySourceCLI","ActivitySourceAPI"]},"contracts.ActivitySummaryResponse":{"properties":{"blocked_count":{"description":"Count of blocked activities","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"rejected_count":{"description":"RejectedCount is the number of calls shed by a concurrency limiter before\nthey reached an upstream (spec 093). Counted separately from errors: it is\nproxy backpressure, not an upstream fault, and it is the signal an\noperator right-sizes max_concurrent_requests against.","type":"integer"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"contracts.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeepScanDescriptor":{"description":"DeepScan reports the opt-in \"deep scan\" layer status (Spec 077 US3),\nSEPARATELY from the baseline verdict above. Always emitted on a computed\nsummary — when deep scan is off (the default) it reports enabled=false\nplus any enabled-but-skipped Docker scanners. It never influences Status.","properties":{"available":{"type":"boolean"},"enabled":{"type":"boolean"},"ran":{"type":"boolean"},"scanners_failed":{"items":{"$ref":"#/components/schemas/contracts.DeepScanScannerFailure"},"type":"array","uniqueItems":false},"skipped_scanners":{"description":"SkippedScanners lists Docker scanners the user enabled that are skipped\nbecause security.deep_scan.enabled is false (informational).","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DeepScanScannerFailure":{"properties":{"id":{"type":"string"},"reason":{"type":"string"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostic":{"description":"Spec 044 — structured diagnostic error and stable error code. Both\nare populated when the server is in a failed state and the error\nhas been classified by internal/diagnostics. Healthy servers omit\nthese fields.","properties":{"cause":{"type":"string"},"code":{"type":"string"},"detected_at":{"type":"string"},"docs_url":{"type":"string"},"fix_steps":{"items":{"$ref":"#/components/schemas/contracts.DiagnosticFixStep"},"type":"array","uniqueItems":false},"severity":{"type":"string"},"user_message":{"type":"string"}},"type":"object"},"contracts.DiagnosticFixStep":{"properties":{"command":{"type":"string"},"destructive":{"type":"boolean"},"fixer_key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.EditRegistrySourceRequest":{"properties":{"name":{"description":"new display name","type":"string"},"servers_url":{"description":"explicit servers-collection URL","type":"string"},"url":{"description":"new base/servers https URL","type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.FindingCounts":{"properties":{"dangerous":{"description":"Tool poisoning, active prompt injection","type":"integer"},"info":{"description":"Low-severity CVEs, informational","type":"integer"},"total":{"type":"integer"},"warning":{"description":"Rug pull, supply chain CVEs with exploits","type":"integer"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object","type":"object"},"config_path":{"description":"Path to config file","type":"string"}},"type":"object"},"contracts.GetRegistriesResponse":{"properties":{"registries":{"items":{"$ref":"#/components/schemas/contracts.Registry"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerLogsResponse":{"properties":{"count":{"type":"integer"},"logs":{"items":{"$ref":"#/components/schemas/contracts.LogEntry"},"type":"array","uniqueItems":false},"server_name":{"type":"string"}},"type":"object"},"contracts.GetServerToolCallsResponse":{"properties":{"server_name":{"type":"string"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerToolsResponse":{"properties":{"count":{"type":"integer"},"server_name":{"type":"string"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GetServersResponse":{"properties":{"servers":{"items":{"$ref":"#/components/schemas/contracts.Server"},"type":"array","uniqueItems":false},"stats":{"$ref":"#/components/schemas/contracts.ServerStats"}},"type":"object"},"contracts.GetSessionDetailResponse":{"properties":{"session":{"$ref":"#/components/schemas/contracts.MCPSession"}},"type":"object"},"contracts.GetSessionsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"sessions":{"items":{"$ref":"#/components/schemas/contracts.MCPSession"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetToolCallDetailResponse":{"properties":{"tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"}},"type":"object"},"contracts.GetToolCallsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GlobalToolsResponse":{"properties":{"failed_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"partial":{"type":"boolean"},"stats":{"$ref":"#/components/schemas/contracts.GlobalToolsStats"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GlobalToolsStats":{"properties":{"disabled":{"type":"integer"},"enabled":{"type":"integer"},"pending_approval":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.HealthStatus":{"description":"Unified health status calculated by the backend","properties":{"action":{"description":"Action is the suggested fix action: \"login\", \"restart\", \"enable\", \"approve\", \"view_logs\", \"set_secret\", \"configure\", or \"\" (none)","type":"string"},"admin_state":{"description":"AdminState indicates the admin state: \"enabled\", \"disabled\", or \"quarantined\"","type":"string"},"detail":{"description":"Detail is an optional longer explanation of the status","type":"string"},"level":{"description":"Level indicates the health level: \"healthy\", \"degraded\", or \"unhealthy\"","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"}},"type":"object"},"contracts.InfoEndpoints":{"description":"Available API endpoints","properties":{"http":{"description":"HTTP endpoint address (e.g., \"127.0.0.1:8080\")","type":"string"},"socket":{"description":"Unix socket path (empty if disabled)","type":"string"}},"type":"object"},"contracts.InfoResponse":{"properties":{"endpoints":{"$ref":"#/components/schemas/contracts.InfoEndpoints"},"launched_by":{"description":"LaunchedBy is the durable launch provenance of the running core (Spec\n092 FR-001a): \"tray\" when a tray spawned it, \"installer\" when the macOS\nPKG postinstall did, \"\" when user-launched or unknown. Always present\n(possibly empty) so a tray can distinguish \"old core, not mine\" from\n\"old core I may supersede\".","type":"string"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"pid":{"description":"PID is the operating-system process id of the running core (Spec 092\nFR-002). A tray that merely ATTACHED to a core holds no Process handle\nfor it, so without this there is no mechanism at all to stop a stale\ncore — the consent action would have nothing to act on and could only\nprint instructions. Paired with LaunchedBy it is what lets a newer tray\nsupersede a core an older tray started.","type":"integer"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"update_policy":{"$ref":"#/components/schemas/contracts.UpdatePolicy"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"network_mode":{"type":"string"},"timeout":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationDefaults":{"description":"IsolationDefaults exposes the resolved baseline values that\nwould apply when no per-server override is set. Populated on\nlist/get responses; never consumed on PATCH requests.","properties":{"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"runtime_type":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.LogEntry":{"properties":{"fields":{"type":"object"},"level":{"type":"string"},"message":{"type":"string"},"server":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.MCPSession":{"properties":{"client_name":{"type":"string"},"client_version":{"type":"string"},"end_time":{"type":"string"},"experimental":{"items":{"type":"string"},"type":"array","uniqueItems":false},"has_roots":{"description":"MCP Client Capabilities","type":"boolean"},"has_sampling":{"type":"boolean"},"id":{"type":"string"},"last_activity":{"type":"string"},"start_time":{"type":"string"},"status":{"type":"string"},"tool_call_count":{"type":"integer"},"total_tokens":{"type":"integer"},"work_session_id":{"type":"string"},"workspace_name":{"description":"Workspace / work session (Spec 082). WorkspaceName is the project's\nbasename — the full local path is never exposed. WorkSessionID groups the\nreconnects that make up one stretch of user work.","type":"string"}},"type":"object"},"contracts.MetadataStatus":{"properties":{"authorization_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"error":{"type":"string"},"found":{"type":"boolean"},"url_checked":{"type":"string"}},"type":"object"},"contracts.MissingSecretInfo":{"properties":{"secret_name":{"type":"string"},"used_by":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.NPMPackageInfo":{"properties":{"exists":{"type":"boolean"},"install_cmd":{"type":"string"}},"type":"object"},"contracts.OAuthConfig":{"properties":{"auth_url":{"type":"string"},"client_id":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_port":{"type":"integer"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_expires_at":{"description":"When the OAuth token expires","type":"string"},"token_url":{"type":"string"},"token_valid":{"description":"Whether token is currently valid","type":"boolean"}},"type":"object"},"contracts.OAuthErrorDetails":{"description":"Structured discovery/failure details","properties":{"authorization_server_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"dcr_status":{"$ref":"#/components/schemas/contracts.DCRStatus"},"protected_resource_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"server_url":{"type":"string"}},"type":"object"},"contracts.OAuthFlowError":{"properties":{"correlation_id":{"description":"Flow tracking ID for log correlation","type":"string"},"debug_hint":{"description":"CLI command for log lookup","type":"string"},"details":{"$ref":"#/components/schemas/contracts.OAuthErrorDetails"},"error_code":{"description":"Machine-readable error code (e.g., OAUTH_NO_METADATA)","type":"string"},"error_type":{"description":"Category of OAuth runtime failure","type":"string"},"message":{"description":"Human-readable error description","type":"string"},"request_id":{"description":"HTTP request ID (from PR #237)","type":"string"},"server_name":{"description":"Server that failed OAuth","type":"string"},"success":{"description":"Always false","type":"boolean"},"suggestion":{"description":"Actionable remediation hint","type":"string"}},"type":"object"},"contracts.OAuthIssue":{"properties":{"documentation_url":{"type":"string"},"error":{"type":"string"},"issue":{"type":"string"},"missing_params":{"items":{"type":"string"},"type":"array","uniqueItems":false},"resolution":{"type":"string"},"server_name":{"type":"string"}},"type":"object"},"contracts.OAuthRequirement":{"properties":{"expires_at":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"state":{"type":"string"}},"type":"object"},"contracts.OAuthStartResponse":{"properties":{"auth_url":{"description":"Authorization URL (always included for manual use)","type":"string"},"browser_error":{"description":"Error message if browser launch failed","type":"string"},"browser_opened":{"description":"Whether browser launch succeeded","type":"boolean"},"correlation_id":{"description":"UUID for tracking this flow","type":"string"},"message":{"description":"Human-readable status message","type":"string"},"server_name":{"description":"Name of the server being authenticated","type":"string"},"success":{"description":"Always true for successful start","type":"boolean"}},"type":"object"},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"blocked_count":{"description":"Number of disabled (blocked) tools","type":"integer"},"changed_count":{"description":"Number of tools whose description/schema changed since approval","type":"integer"},"pending_count":{"description":"Number of newly discovered tools awaiting approval","type":"integer"}},"type":"object"},"contracts.RefreshRegistryResponse":{"properties":{"cleared":{"description":"number of cached entries dropped","type":"integer"},"registry_id":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"deep_scan":{"$ref":"#/components/schemas/contracts.DeepScanDescriptor"},"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary (baseline) scan pass — informational only.\nSpec 077 US3 (FR-008/FR-014): Status is derived SOLELY from the\ndeterministic baseline findings; a failed Docker deep scanner no longer\ndowngrades a clean verdict. That failure is surfaced via DeepScan instead.","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"init_timeout":{"description":"InitTimeout mirrors config.ServerConfig.InitTimeout (MCP-3322 / GH #760):\nthe per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override. Serialized as\na duration string (e.g. \"120s\"); nil/omitted means \"inherit the global\ndefault\". Surfaced on the GET path so clients can read back a configured\noverride; PATCH/POST accept it via AddServerRequest.","type":"string"},"isolation":{"$ref":"#/components/schemas/contracts.IsolationConfig"},"isolation_defaults":{"$ref":"#/components/schemas/contracts.IsolationDefaults"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"max_concurrent_requests":{"description":"Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of\nFR-020. Each setting is tri-state: nil (omitted) means \"inherit\nserver_concurrency_defaults\", 0 disables that setting for this server,\npositive overrides it. Surfaced on the GET path so a caller can read back\nwhat it set; PATCH/POST accept them via AddServerRequest. The effective\nconcurrency for a server is additionally bounded by the global aggregate\nlimiter, which is NOT an inheritance source for these fields.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"security_scan":{"$ref":"#/components/schemas/contracts.SecurityScanSummary"},"should_retry":{"type":"boolean"},"source_registry_id":{"description":"MCP-901 — registry provenance of an upstream that was added from a\nregistry. SourceRegistryID names the source registry (empty for\nmanually-configured servers); SourceRegistryProvenance is the trust tag\nrecorded at add time (\"official/trusted\" or \"custom/unverified\"). Both\nare projected from config.ServerConfig so the approval/quarantine view\ncan render an \"added from \u003cregistry\u003e · unverified\" origin badge. Optional\nand omitted when empty — clients that pre-date this treat them as absent.","type":"string"},"source_registry_provenance":{"type":"string"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"trust_mode":{"description":"TrustMode mirrors config.ServerConfig.TrustMode (spec 086): the per-server\ntrust tier (\"auto\"/\"scan\"/\"manual\"). Surfaced on the GET path so clients can\nread back the persisted mode; PATCH/POST accept it via AddServerRequest.\nOmitted when empty (server predates the field / relies on legacy flags).","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"user_logged_out":{"description":"True if user explicitly logged out (prevents auto-reconnection)","type":"boolean"},"working_dir":{"type":"string"}},"type":"object"},"contracts.ServerActionResponse":{"properties":{"action":{"type":"string"},"async":{"type":"boolean"},"server":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ServerStats":{"properties":{"connected_servers":{"type":"integer"},"docker_containers":{"type":"integer"},"quarantined_servers":{"type":"integer"},"token_metrics":{"$ref":"#/components/schemas/contracts.ServerTokenMetrics"},"total_servers":{"type":"integer"},"total_tools":{"type":"integer"}},"type":"object"},"contracts.ServerTokenMetrics":{"properties":{"average_query_result_size":{"description":"Typical retrieve_tools output (tokens)","type":"integer"},"per_server_tool_list_sizes":{"additionalProperties":{"type":"integer"},"description":"Token size per server","type":"object"},"saved_tokens":{"description":"Difference","type":"integer"},"saved_tokens_percentage":{"description":"Percentage saved","type":"number"},"total_server_tool_list_size":{"description":"All upstream tools combined (tokens)","type":"integer"}},"type":"object"},"contracts.SuccessResponse":{"properties":{"data":{"type":"object"},"success":{"type":"boolean"}},"type":"object"},"contracts.TokenMetrics":{"description":"Token usage metrics (nil for older records)","properties":{"encoding":{"description":"Encoding used (e.g., cl100k_base)","type":"string"},"estimated_cost":{"description":"Optional cost estimate","type":"number"},"input_tokens":{"description":"Tokens in the request","type":"integer"},"model":{"description":"Model used for tokenization","type":"string"},"output_tokens":{"description":"Tokens in the response","type":"integer"},"total_tokens":{"description":"Total tokens (input + output)","type":"integer"},"truncated_tokens":{"description":"Tokens removed by truncation","type":"integer"},"was_truncated":{"description":"Whether response was truncated","type":"boolean"}},"type":"object"},"contracts.Tool":{"properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"approval_status":{"type":"string"},"config_denied":{"description":"ConfigDenied is true when the tool is denied by the server's static\nenabled_tools / disabled_tools config. The user cannot override this toggle.","type":"boolean"},"description":{"type":"string"},"disabled":{"description":"Disabled mirrors ToolApprovalRecord.Disabled so per-tool enable state is\navailable without a second round-trip to the approvals endpoint. Absent\nin the JSON when false (default) to keep responses compact.","type":"boolean"},"held_reason":{"description":"HeldReason, HeldVerdict and HeldSignals mirror the same-named fields on\nstorage.ToolApprovalRecord: the offline-scan evidence that made\ntrust_mode: scan hold this tool for review (spec 086 FR-018). HeldSignals\nnames the matched deterministic check ids, e.g.\n\"tpa.TPA-2026-0001.hidden_instruction\", so a reviewer can see WHY the tool\nis held. All three are omitted for tools that are not held by the scan gate\n(including every record written before the field existed).","type":"string"},"held_signals":{"items":{"type":"string"},"type":"array","uniqueItems":false},"held_verdict":{"type":"string"},"last_used":{"type":"string"},"name":{"type":"string"},"schema":{"type":"object"},"server_name":{"type":"string"},"usage":{"type":"integer"}},"type":"object"},"contracts.ToolAnnotation":{"description":"Tool behavior hints snapshot","properties":{"destructiveHint":{"type":"boolean"},"idempotentHint":{"type":"boolean"},"openWorldHint":{"type":"boolean"},"readOnlyHint":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"contracts.ToolCallRecord":{"description":"The new tool call record","properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"arguments":{"description":"Tool arguments","type":"object"},"config_path":{"description":"Active config file path","type":"string"},"duration":{"description":"Duration in nanoseconds","type":"integer"},"error":{"description":"Error message (failure only)","type":"string"},"execution_type":{"description":"\"direct\" or \"code_execution\"","type":"string"},"id":{"description":"Unique identifier","type":"string"},"mcp_client_name":{"description":"MCP client name from InitializeRequest","type":"string"},"mcp_client_version":{"description":"MCP client version","type":"string"},"mcp_session_id":{"description":"MCP session identifier","type":"string"},"metrics":{"$ref":"#/components/schemas/contracts.TokenMetrics"},"parent_call_id":{"description":"Links nested calls to parent code_execution","type":"string"},"request_id":{"description":"Request correlation ID","type":"string"},"response":{"description":"Tool response (success only)","type":"object"},"server_id":{"description":"Server identity hash","type":"string"},"server_name":{"description":"Human-readable server name","type":"string"},"timestamp":{"description":"When the call was made","type":"string"},"tool_name":{"description":"Tool name (without server prefix)","type":"string"}},"type":"object"},"contracts.UpdateInfo":{"description":"Update information (if available)","properties":{"available":{"description":"Whether an update is available","type":"boolean"},"check_error":{"description":"Error message if update check failed","type":"string"},"checked_at":{"description":"When the update check was performed","type":"string"},"install_channel":{"description":"Detected install channel (homebrew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown) — Spec 079 FR-008","type":"string"},"is_prerelease":{"description":"Whether the latest version is a prerelease","type":"boolean"},"latest_version":{"description":"Latest version available (e.g., \"v1.2.3\")","type":"string"},"nudges_suppressed":{"description":"UI surfaces must stay quiet (CI / non-interactive context); machine-readable fields still report the facts — Spec 079 FR-019","type":"boolean"},"release_url":{"description":"URL to the release page","type":"string"},"update_command":{"description":"One-line update command for the channel; only set when an update is available and the channel has one — Spec 079 FR-009","type":"string"}},"type":"object"},"contracts.UpdatePolicy":{"description":"UpdatePolicy is the effective, hot-reloadable update policy (Spec 092\nFR-015). Always present: the ` + "`" + `update` + "`" + ` object above is omitted both when\nupdate checking is disabled AND when no check has produced a result\nyet, so its absence cannot tell a client whether it is allowed to run\nits own (e.g. Sparkle feed) check. This field states the answer.","properties":{"channel":{"description":"Channel is the tracked release channel: \"stable\" or \"rc\".","type":"string"},"enabled":{"description":"Enabled is the effective automatic-check kill switch: update_check.enabled\nwith MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated\n\"Check for Updates\" stays available regardless.","type":"boolean"},"nudges_suppressed":{"description":"NudgesSuppressed asks UI surfaces to stay quiet (CI / non-interactive)\nwhile machine-readable fields keep reporting the facts.","type":"boolean"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.UsageAggregateResponse":{"properties":{"freshness_ms":{"description":"age of the underlying snapshot in ms","type":"integer"},"generated_at":{"type":"string"},"other":{"$ref":"#/components/schemas/contracts.UsageOtherBucket"},"timeline":{"items":{"$ref":"#/components/schemas/contracts.UsageTimeBucket"},"type":"array","uniqueItems":false},"token_source":{"description":"\"bytes\" (size-based proxy, FR-006)","type":"string"},"tokens_saved":{"description":"echoed from ServerTokenMetrics (FR-007)","type":"integer"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"window":{"type":"string"}},"type":"object"},"contracts.UsageOtherBucket":{"description":"present only when the list was truncated to top-N","properties":{"calls":{"type":"integer"},"tools_folded":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageTimeBucket":{"properties":{"calls":{"type":"integer"},"errors":{"type":"integer"},"start":{"type":"string"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageToolStat":{"properties":{"avg_req_bytes":{"description":"null when no sized request calls","type":"integer"},"avg_resp_bytes":{"description":"null when sized_calls == 0 (only legacy 0-byte calls)","type":"integer"},"blocked":{"type":"integer"},"calls":{"type":"integer"},"error_rate":{"type":"number"},"errors":{"type":"integer"},"last_used":{"type":"string"},"p50_ms":{"type":"integer"},"p95_ms":{"type":"integer"},"rejected":{"description":"spec 093: shed by a concurrency limit; never executed, so excluded from calls/latency","type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"init_timeout":{"description":"InitTimeout is the per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override\n(MCP-3322 / GH #760), serialized as a duration string (e.g. \"120s\"). A nil\npointer means \"leave unchanged\" on PATCH; a present value is applied.\nMirrors config.ServerConfig.InitTimeout's *Duration tri-state.","type":"string"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"max_concurrent_requests":{"description":"MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server\nconcurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is\ntri-state: a nil pointer means \"leave unchanged\" on PATCH and \"inherit\nserver_concurrency_defaults\" on create; an explicit 0 disables that\nsetting for this server; a positive value overrides it. Do NOT collapse\nthem to plain values — an omitted field would then silently reset a\nconfigured limit.","type":"integer"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"type":"boolean"},"trust_mode":{"description":"TrustMode is the per-server trust tier (spec 086): \"auto\", \"scan\", or\n\"manual\". Empty means \"leave unchanged\" on PATCH (and inherit the migrated\ndefault on create). A non-empty value is applied to ServerConfig.TrustMode\nand resolved by EffectiveTrustMode (an unrecognized value fails closed to\nmanual). This is the REST seam for changing the trust tier via\nPOST/PATCH /api/v1/servers.","type":"string"},"url":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.CanonicalConfigPath":{"properties":{"description":{"description":"Brief description","type":"string"},"exists":{"description":"Whether the file exists","type":"boolean"},"format":{"description":"Format identifier (e.g., \"claude_desktop\")","type":"string"},"name":{"description":"Display name (e.g., \"Claude Desktop\")","type":"string"},"os":{"description":"Operating system (darwin, windows, linux)","type":"string"},"path":{"description":"Full path to the config file","type":"string"}},"type":"object"},"httpapi.CanonicalConfigPathsResponse":{"properties":{"os":{"description":"Current operating system","type":"string"},"paths":{"description":"List of canonical config paths","items":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPath"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ConnectConflictResponse":{"properties":{"action":{"description":"already_exists | precondition_failed","type":"string"},"data":{"$ref":"#/components/schemas/connect.ConnectResult"},"error":{"description":"Human-readable message","type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"precondition_token":{"description":"PreconditionToken is the opaque token from the preview this write was\nconfirmed against (Spec 091 FR-005). When present, the core rechecks it\nat write time and responds 409 with action \"precondition_failed\" —\nwriting nothing — if the config or the entry MCPProxy would write has\ndrifted since; the caller then re-previews instead of retrying. Absent\nmeans exactly the pre-091 behavior. A replace-classified flow sends this\nTOGETHER with force=true: the token, not the absence of force, is the\noverwrite safety.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.ImportFromPathRequest":{"properties":{"format":{"description":"Optional format hint","type":"string"},"path":{"description":"File path to import from","type":"string"},"rename":{"additionalProperties":{"type":"string"},"description":"Rename maps a server name → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys are matched against\neither the raw source name (OriginalName) or the sanitized name shown\nin the preview (Server.Name); these differ for names that need\nsanitizing (e.g. \"Figma Desktop\" → \"Figma_Desktop\"). Keys not present\nin the imported set are ignored.","type":"object"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportRequest":{"properties":{"content":{"description":"Raw JSON or TOML content","type":"string"},"format":{"description":"Optional format hint","type":"string"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportResponse":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/configimport.FailedServer"},"type":"array","uniqueItems":false},"format":{"type":"string"},"format_name":{"type":"string"},"imported":{"items":{"$ref":"#/components/schemas/httpapi.ImportedServerResponse"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/configimport.SkippedServer"},"type":"array","uniqueItems":false},"summary":{"$ref":"#/components/schemas/configimport.ImportSummary"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportedServerResponse":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (image,\nnetwork_mode, extra_args, working_dir, enabled). A nil pointer\nmeans \"do not touch isolation config\"; an empty-but-present\nobject on PATCH intentionally clears the overrides.","properties":{"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.OnboardingMarkRequest":{"properties":{"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value. The stored enum is wider (Spec 080\nFR-001): a \"skipped\" request for a previously untouched connect step\nis upgraded server-side to \"completed_external\" when the install\nshows positive evidence of an external connection (Spec 080 FR-002).\n\"completed_external\" is NOT accepted from clients — it must never be\npersisted without that server-verified evidence (edge case: \"never\nguess completed_external without positive evidence\").","type":"string"},"engaged":{"description":"Engaged marks the wizard as engaged (completed or explicitly skipped).\nOnce true, the wizard does not auto-show again.","type":"boolean"},"mark_shown":{"description":"MarkShown records the wizard's first display time if not already set.","type":"boolean"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","type":"string"}},"type":"object"},"httpapi.SetActiveProfileRequest":{"properties":{"active_profile":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.UndoConnectRequest":{"properties":{"backup_name":{"description":"BackupName is the bare filename (filepath.Base) of the backup returned as\nbackup_path by the preceding connect — a name, never a path. Undo resolves\nthe full path server-side by joining it with the client's own config\ndirectory, so a client-supplied value can never contribute a directory\ncomponent (traversal is impossible by construction). Empty means the\nconnect created the file (no prior file existed), so undo removes it.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.UpdateFailureRequest":{"properties":{"stage":{"description":"Stage is the failure stage of the update session.","enum":["appcast","download","install","other"],"type":"string"}},"type":"object"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned upstream servers (MCP-2769). It is OFF by\ndefault and deliberately kept out of the AllowedSystemVars default list:\nproxy URLs frequently carry credentials (http://user:pass@proxy), so\nforwarding them to every stdio upstream is a credential-leak risk. When\nenabled, values are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"},"telemetry.FeedbackContext":{"properties":{"arch":{"type":"string"},"connected_server_count":{"type":"integer"},"edition":{"type":"string"},"os":{"type":"string"},"routing_mode":{"type":"string"},"server_count":{"type":"integer"},"version":{"type":"string"}},"type":"object"},"telemetry.FeedbackRequest":{"properties":{"category":{"description":"bug, feature, other","type":"string"},"context":{"$ref":"#/components/schemas/telemetry.FeedbackContext"},"email":{"type":"string"},"message":{"type":"string"}},"type":"object"},"telemetry.FeedbackResponse":{"properties":{"error":{"type":"string"},"issue_url":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"description":"API key authentication via query parameter. Use ?apikey=your-key","in":"query","name":"apikey","type":"apiKey"}}}, + "components": {"schemas":{"config.ConcurrencyDefaults":{"description":"ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server\ndefault set inherited by every server that does not override a setting.\nAbsent (the default) = no per-server limiting unless a server configures\nit explicitly. File/API-configured only — no env scheme (FR-022).","properties":{"max_concurrent_requests":{"type":"integer"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"}},"type":"object"},"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_max_size_mb":{"description":"Max total activity-log size in MB before pruning oldest (default: 256, 0=disabled)","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"allow_private_registry_fetch":{"description":"AllowPrivateRegistryFetch opts out of the registry SSRF guard (MCP-1076,\nCWE-918). By default (false) registry fetches refuse any host that is — or\nresolves to — a non-routable address (loopback, RFC1918/CGNAT private,\nlink-local incl. the 169.254.169.254 cloud-metadata endpoint), so a\nmalicious or typo'd registry source cannot turn the daemon into a\nrequest-forgery vector against internal services.\n\nThis opt-out is BLANKET (all-or-nothing): setting it true disables the\nguard for EVERY non-routable range at once — loopback, RFC1918/CGNAT\nprivate, link-local AND the 169.254.169.254 cloud-metadata endpoint. There\nis no way to allow only loopback; enabling it for a localhost dev registry\nalso re-opens the cloud-metadata SSRF vector. Set true ONLY when you\nintentionally run a trusted registry mirror on an internal/private address,\nideally on a host with no cloud-metadata exposure. The change takes effect\nonly on daemon (re)start or config reload.","type":"boolean"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"api_key":{"description":"Security settings","type":"string"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_parallel":{"description":"Default concurrency for call_tools() batches (1-32, default: 8)","type":"integer"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"disable_management":{"type":"boolean"},"docker_isolation":{"$ref":"#/components/schemas/config.DockerIsolationConfig"},"docker_recovery":{"$ref":"#/components/schemas/config.DockerRecoveryConfig"},"enable_code_execution":{"description":"Code execution settings","type":"boolean"},"enable_prompts":{"description":"Prompts settings","type":"boolean"},"enable_socket":{"description":"Enable Unix socket/named pipe for local IPC (default: true)","type":"boolean"},"enable_tray":{"description":"Deprecated: EnableTray is unused and has no runtime effect. Kept for backward compatibility.","type":"boolean"},"environment":{"$ref":"#/components/schemas/secureenv.EnvConfig"},"features":{"$ref":"#/components/schemas/config.FeatureFlags"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"http_idle_timeout":{"description":"HTTPIdleTimeout caps how long an idle keep-alive connection is kept open.\nUnset = 180s. \"0s\" removes the dedicated idle deadline, but net/http then\nfalls back to ReadTimeout — idle is fully unbounded only when\nhttp_read_timeout is also \"0s\". Requires a restart.","type":"string"},"http_read_timeout":{"description":"HTTPReadTimeout caps how long reading a whole request (headers + body)\nmay take. Unset = 120s; \"0s\" disables it. Requires a restart.","type":"string"},"http_write_timeout":{"description":"HTTPWriteTimeout caps how long producing a whole response may take on\nnon-streaming endpoints (REST, Web UI, health). Unset = 120s; \"0s\"\ndisables it globally. MCP and SSE /events routes are exempt by design.","type":"string"},"init_timeout":{"description":"InitTimeout is the global default deadline for an upstream's MCP\n` + "`" + `initialize` + "`" + ` handshake (MCP-3322 / GH #760). *Duration tri-state: nil =\ninherit the built-in 30s default; a positive value = that deadline. A\nper-server InitTimeout overrides this. Resolved by ResolveInitTimeout;\nvalidated to {0} ∪ [1s, 30m] in Validate(). Servers doing legitimate\nfirst-run warmup (cache/index build) before answering ` + "`" + `initialize` + "`" + ` can\nraise this so they are not killed mid-startup.","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_concurrent_requests":{"description":"Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL\nAGGREGATE limiter — one proxy-wide cap on concurrently running upstream\ntool calls, with its own bounded wait queue. Tri-state pointers: absent =\nthe limiter does not exist (default, zero behavior change); an explicit 0\nmax also disables it; positive = that cap. This scope is NEVER a\nper-server inheritance source — per-server values come from\nServerConcurrencyDefaults / the per-server overrides — but a server's\neffective concurrency is bounded by BOTH its own limiter and this one.\nResolved by ResolveGlobalConcurrency; hot-reloadable; overridable via\nMCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).","type":"integer"},"max_result_size_chars":{"description":"Advertised on every tool as ` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; raises Claude Code's inline-response ceiling from 50k to up to 500k chars. Set to 0 to disable.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of the\nsecret-bearing server fields — sensitive header values (Authorization,\nX-API-Key, Cookie, …), env-var secrets, and URL query credentials — in\nresponses from the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + `\nREST API, and the SSE event stream. It also lets URL secrets echoed\ninto last_error / health.detail through unscrubbed.\n\nDefault false — sensitive values are surfaced masked as\n` + "`" + `••••\u003clast2\u003e (\u003cN\u003e chars)` + "`" + ` (error strings use ` + "`" + `***REDACTED***` + "`" + `) so an\nMCP agent cannot read Bearer tokens / API keys / URL secrets out of\nanother upstream's config (PR #425, issue #872). ${env:…}/${keyring:…}\nreferences are labels, not secrets, and pass through unchanged.\n\nThe Web UI / macOS tray edit forms work without seeing the real\nvalues: PATCH /api/v1/servers/{id} deep-merges (omitted keys are\npreserved, see ` + "`" + `headers_remove` + "`" + ` / ` + "`" + `env_remove` + "`" + ` for explicit\ndeletes), so clients compute a diff and only send the keys that\nactually changed. Redacted-but-unchanged values never round-trip\n— the backend keeps the real string. Set this to true if a\ndownstream tool genuinely needs raw values in the response.","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"security":{"$ref":"#/components/schemas/config.SecurityConfig"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"server_concurrency_defaults":{"$ref":"#/components/schemas/config.ConcurrencyDefaults"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_discovery_interval":{"type":"string"},"tool_response_limit":{"type":"integer"},"tool_response_mode":{"description":"Tool response mode (Spec 085): how retrieve_tools serializes results.\nValid values: \"\" (= full), \"full\" (default: today's schema-bearing\nentries), \"compact\" (signature + first-sentence entries). Orthogonal to\nrouting_mode — routing_mode selects the tool SURFACE, this selects the\nSERIALIZATION within the retrieve_tools surface. Serialization-only: it\nnever affects the query, ranking, or result set. Hot-reloadable.","type":"string"},"tool_response_session_risk_warning":{"description":"ToolResponseSessionRiskWarning controls whether the prose ` + "`" + `warning` + "`" + ` field\nis included in the ` + "`" + `session_risk` + "`" + ` object returned by ` + "`" + `retrieve_tools` + "`" + `.\nThe structured fields (level, lethal_trifecta, has_open_world_tools, etc.)\nare always included. Default: false (quiet for LLM clients) — see issue #406.\nMost tools lack annotations, so the MCP-spec defaults treat them as fully\npermissive across all three risk axes, which makes the prose warning fire\non almost every call and wastes tokens.","type":"boolean"},"tools_limit":{"type":"integer"},"toon_min_savings_pct":{"description":"ToonMinSavingsPct is the minimum byte-savings percentage (validated\n1-90; 0/unset → 15) the complete TOON emission (marker + hint + body)\nmust achieve over the exact passthrough emission for adaptive mode to\nencode a block. Byte savings approximate token savings for the tabular\npayload class; the spec-083 profiler reports true token deltas.\nGlobal-only (no per-server override, FR-001).","type":"integer"},"toon_output":{"description":"ToonOutput selects the TOON encoding mode for call_tool_* result text\nblocks (spec 084): \"off\" (default — responses byte-identical to\npre-feature behavior), \"adaptive\" (encode only tabular-uniform payloads\nthat beat compact JSON by ToonMinSavingsPct), or \"always\"\n(benchmark/debug only — encodes every JSON-parseable block and can\nINCREASE token cost). Per-server override: ServerConfig.ToonOutput.\nResolved by ResolveToonOutput; hot-reloadable.","type":"string"},"top_k":{"description":"Deprecated: TopK is superseded by ToolsLimit and has no runtime effect. Kept for backward compatibility.","type":"integer"},"tray_endpoint":{"description":"Tray endpoint override (unix:// or npipe://)","type":"string"},"trusted_hosts":{"description":"TrustedHosts lists non-loopback Host header values accepted on loopback\nlisteners (GH #898). DNS-rebinding protection rejects requests whose Host\nheader is not a loopback address when mcpproxy listens on loopback; a\nreverse proxy (nginx → 127.0.0.1) forwarding the public domain in Host\ntrips it. Entries are hostnames, case-insensitive; an entry without a\nport matches any port, with a port it must match exactly; a leading dot\n(\".example.com\") is a subdomain wildcard. The single entry \"*\" disables\nHost and Origin validation entirely. The same list also validates the\nOrigin header when present (MCP spec DNS-rebinding defense). Empty\n(default) keeps full protection. Env override: MCPPROXY_TRUSTED_HOSTS\n(comma-separated).","items":{"type":"string"},"type":"array","uniqueItems":false},"update_check":{"$ref":"#/components/schemas/config.UpdateCheckConfig"}},"type":"object"},"config.CustomPattern":{"properties":{"category":{"description":"Category (defaults to \"custom\")","type":"string"},"keywords":{"description":"Keywords to match (mutually exclusive with Regex)","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Unique identifier for this pattern","type":"string"},"regex":{"description":"Regex pattern (mutually exclusive with Keywords)","type":"string"},"severity":{"description":"Risk level: critical, high, medium, low","type":"string"}},"type":"object"},"config.DeepScanConfig":{"description":"DeepScan is the opt-in \"deep scan\" layer (Spec 077 US3). It subsumes the\ndeprecated top-level scanner_fetch_package_source / scanner_disable_no_new_privileges\nkeys (migrated on load) and gates the heavy Docker-based scanners + source\nextraction. Disabled by default (FR-006): only the deterministic in-process\nbaseline scanner runs. A deep-scan failure NEVER changes the baseline verdict\n(FR-007/FR-008).","properties":{"disable_no_new_privileges":{"description":"DisableNoNewPrivileges, when true, omits the ` + "`" + `--security-opt\nno-new-privileges` + "`" + ` flag from scanner container runs (snap-docker/AppArmor\nescape hatch). Absorbs the deprecated top-level\nscanner_disable_no_new_privileges. Default false.","type":"boolean"},"enabled":{"description":"Enabled is the master opt-in for the heavy layer (FR-006). Default false.","type":"boolean"},"fetch_package_source":{"description":"FetchPackageSource controls whether the scanner fetches the PUBLISHED\nsource of package-runner servers (npx/uvx) — without executing it — when\nno local source is available. Absorbs the deprecated top-level\nscanner_fetch_package_source. Default (nil) is ENABLED within deep scan.","type":"boolean"},"scanners":{"description":"Scanners optionally restricts which deep scanners may run under the\numbrella (by scanner id). Empty ⇒ all enabled deep scanners are eligible.","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.DockerIsolationConfig":{"description":"Docker isolation settings","properties":{"cpu_limit":{"description":"CPU limit for containers","type":"string"},"default_images":{"additionalProperties":{"type":"string"},"description":"Map of runtime type to Docker image","type":"object"},"enable_cache_volume":{"description":"Mount shared cache volumes for faster restarts (default: true)","type":"boolean"},"enabled":{"description":"Global enable/disable for Docker isolation (legacy; superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments","items":{"type":"string"},"type":"array","uniqueItems":false},"log_driver":{"description":"Docker log driver (default: json-file)","type":"string"},"log_max_files":{"description":"Maximum number of log files (default: 3)","type":"string"},"log_max_size":{"description":"Maximum size of log files (default: 100m)","type":"string"},"memory_limit":{"description":"Memory limit for containers","type":"string"},"mode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"network_mode":{"description":"Docker network mode (default: bridge)","type":"string"},"registry":{"description":"Custom registry (defaults to docker.io)","type":"string"},"timeout":{"description":"Container startup timeout","type":"string"}},"type":"object"},"config.DockerRecoveryConfig":{"description":"Docker recovery settings","properties":{"enabled":{"description":"Enable Docker recovery monitoring (default: true)","type":"boolean"},"max_retries":{"description":"Maximum retry attempts (0 = unlimited)","type":"integer"},"notify_on_failure":{"description":"Show notification on recovery failure (default: true)","type":"boolean"},"notify_on_retry":{"description":"Show notification on each retry (default: false)","type":"boolean"},"notify_on_start":{"description":"Show notification when recovery starts (default: true)","type":"boolean"},"notify_on_success":{"description":"Show notification on successful recovery (default: true)","type":"boolean"},"persistent_state":{"description":"Save recovery state across restarts (default: true)","type":"boolean"}},"type":"object"},"config.FeatureFlags":{"description":"Deprecated: Features flags are unused and have no runtime effect. Kept for backward compatibility.","properties":{"enable_async_storage":{"type":"boolean"},"enable_caching":{"type":"boolean"},"enable_contract_tests":{"type":"boolean"},"enable_debug_logging":{"description":"Development features","type":"boolean"},"enable_docker_isolation":{"type":"boolean"},"enable_event_bus":{"type":"boolean"},"enable_health_checks":{"type":"boolean"},"enable_metrics":{"type":"boolean"},"enable_oauth":{"description":"Security features","type":"boolean"},"enable_observability":{"description":"Observability features","type":"boolean"},"enable_quarantine":{"type":"boolean"},"enable_runtime":{"description":"Runtime features","type":"boolean"},"enable_search":{"description":"Storage features","type":"boolean"},"enable_sse":{"type":"boolean"},"enable_tracing":{"type":"boolean"},"enable_tray":{"type":"boolean"},"enable_web_ui":{"description":"UI features","type":"boolean"}},"type":"object"},"config.IntentDeclarationConfig":{"description":"Intent declaration settings (Spec 018)","properties":{"strict_server_validation":{"description":"StrictServerValidation controls whether server annotation mismatches\ncause rejection (true) or just warnings (false).\nDefault: true (reject mismatches)","type":"boolean"}},"type":"object"},"config.IsolationConfig":{"description":"Per-server isolation settings","properties":{"enabled":{"description":"Enable Docker isolation for this server (nil = inherit global; legacy, superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments for this server","items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"description":"Custom Docker image (overrides default)","type":"string"},"log_driver":{"description":"Docker log driver override for this server","type":"string"},"log_max_files":{"description":"Maximum number of log files override","type":"string"},"log_max_size":{"description":"Maximum size of log files override","type":"string"},"mode":{"$ref":"#/components/schemas/config.IsolationMode"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"config.IsolationMode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"config.LogConfig":{"description":"Logging configuration","properties":{"compress":{"type":"boolean"},"enable_console":{"type":"boolean"},"enable_file":{"type":"boolean"},"filename":{"type":"string"},"json_format":{"type":"boolean"},"level":{"type":"string"},"log_dir":{"description":"Custom log directory","type":"string"},"max_age":{"description":"days","type":"integer"},"max_backups":{"description":"number of backup files","type":"integer"},"max_size":{"description":"MB","type":"integer"}},"type":"object"},"config.MetricsExporterConfig":{"description":"Metrics gates the Prometheus /metrics scrape endpoint (MCP-32). Disabled\nby default — operators opt in for k8s/enterprise deployments.","properties":{"enabled":{"description":"Enabled exposes /metrics on the existing HTTP listener when true.","type":"boolean"}},"type":"object"},"config.OAuthConfig":{"description":"OAuth configuration (keep even when empty to signal OAuth requirement)","properties":{"client_id":{"type":"string"},"client_secret":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters (e.g., RFC 8707 resource)","type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_uri":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ObservabilityConfig":{"description":"Observability settings (Spec 069): usage aggregate cache/persistence cadence.","properties":{"metrics":{"$ref":"#/components/schemas/config.MetricsExporterConfig"},"tracing":{"$ref":"#/components/schemas/config.TracingExporterConfig"},"usage_cache_ttl":{"description":"UsageCacheTTL bounds the freshness of the usage endpoint's read cache for\nwide windows (FR-005). Default 5s.","type":"string"},"usage_persist_interval":{"description":"UsagePersistInterval is how often the actor-owned usage aggregate snapshot\nis flushed to storage. Default 30s.","type":"string"}},"type":"object"},"config.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.ProfileConfig":{"properties":{"name":{"description":"URL slug, validated","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"deep_scan":{"$ref":"#/components/schemas/config.DeepScanConfig"},"integrity_check_interval":{"type":"string"},"integrity_check_on_restart":{"type":"boolean"},"runtime_read_only":{"type":"boolean"},"runtime_tmpfs_size":{"type":"string"},"scan_timeout_default":{"type":"string"},"scanner_disable_no_new_privileges":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.DisableNoNewPrivileges\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.IsDisableNoNewPrivileges. Cleared after migration.\n\nScannerDisableNoNewPrivileges, when true, omits the\n` + "`" + `--security-opt no-new-privileges` + "`" + ` flag from scanner container runs.\n\nBackground: snap-installed Docker on Ubuntu confines dockerd under the\n` + "`" + `snap.docker.dockerd` + "`" + ` AppArmor profile. When runc tries to transition\nthe container into the inner ` + "`" + `docker-default` + "`" + ` profile to exec the\nentrypoint, AppArmor refuses the transition because NO_NEW_PRIVS\nforbids privilege/profile changes on exec — the result is EPERM\n(\"operation not permitted\") and every scanner fails immediately.\n\nSet this to true ONLY on hosts hitting that incompatibility. Scanner\ncontainers still run with read-only rootfs, tmpfs /tmp, no-network by\ndefault, and read-only source mounts, so the marginal isolation loss\nis small. The preferred fix remains replacing snap docker with a\ndistro-packaged docker.","type":"boolean"},"scanner_fetch_package_source":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.FetchPackageSource\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.EffectiveFetchPackageSource. Cleared after migration.\n\nScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"},"tpa_bundle_path":{"description":"TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json\nthe offline TPA scanner runs (spec 086 FR-019: the signature-DB location\nMUST be configuration-driven, not hardcoded). Empty (the default) runs the\ncorpus embedded in this build.\n\nEnv override: MCPPROXY_TPA_BUNDLE_PATH. Hot-reloadable — the path is\nre-read on every config.reloaded event via\nscanner.Service.ApplySecurityConfig, so a corpus refresh needs no restart.\nA configured bundle that fails to read/parse/version-check/compile is\nREFUSED and the previously active corpus stays live (fail-closed, never\nfail-empty); the reason is logged and surfaced in the security overview's\nsignature_bundle.load_error.","type":"string"}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"init_timeout":{"description":"InitTimeout overrides the global init_timeout for this server's MCP\n` + "`" + `initialize` + "`" + ` handshake deadline (MCP-3322 / GH #760). *Duration tri-state:\nnil = inherit the global value (or 30s default), positive = that deadline.\nResolved by Config.ResolveInitTimeout; validated to {0} ∪ [1s, 30m]. Raise\nthis for upstreams that do legitimate first-run warmup (e.g. caching many\nchannels/users) before responding to ` + "`" + `initialize` + "`" + `.","type":"string"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"launcher_wait_timeout":{"description":"LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched\nHTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted\nwhen the server is configured with both Command and an HTTP/SSE URL — i.e.,\nmcpproxy starts the process AND connects via network. Stdio servers ignore\nthis field. Zero or unset → 30s default.","type":"string"},"max_concurrent_requests":{"description":"Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).\nTri-state per setting, exactly like HealthCheckInterval: absent = inherit\nthe per-server default set (server_concurrency_defaults), explicit 0 =\ndisable that setting for this server (0 max = no per-server limiter at\nall; 0 queue_size = no pending capacity, shed immediately at the cap),\npositive = override. The global aggregate limiter is never inherited from\nhere — it applies on top, so effective concurrency is min(per-server,\nglobal). Resolved by Config.ResolveServerConcurrency.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/config.OAuthConfig"},"protocol":{"description":"stdio, http, sse, streamable-http, auto","type":"string"},"quarantined":{"description":"Security quarantine status","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets a disconnected server","type":"boolean"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"toon_output":{"description":"ToonOutput overrides the global toon_output mode for this server's\ntools (spec 084, FR-001). Plain string, not a pointer: \"\"/absent =\ninherit the global value; \"off\"|\"adaptive\"|\"always\" = override (\"off\"\nis the explicit force-off). Resolved by Config.ResolveToonOutput.","type":"string"},"trust_mode":{"description":"TrustMode is the per-server trust tier: auto|scan|manual. Supersedes\nauto_approve_tool_changes (spec 086). An empty value is derived from the\nlegacy fields at load via normalizeServerQuarantineFlags; the single\nresolution point is EffectiveTrustMode(), which treats an empty or\nunrecognized value as manual (secure by default). Read via\nEffectiveTrustMode(), never the raw string.","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TelemetryConfig":{"description":"Telemetry settings (Spec 036)","properties":{"anonymous_id":{"description":"Auto-generated UUIDv4","type":"string"},"anonymous_id_created_at":{"description":"Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"},"last_reported_version":{"description":"Upgrade funnel","type":"string"},"last_startup_outcome":{"description":"success|port_conflict|db_locked|...","type":"string"},"notice_shown":{"description":"First-run notice flag","type":"boolean"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"config.TracingExporterConfig":{"description":"Tracing gates the OpenTelemetry OTLP trace exporter (MCP-32). Disabled by\ndefault.","properties":{"enabled":{"description":"Enabled turns on OTLP trace export for tool calls and upstream hops.","type":"boolean"},"endpoint":{"description":"Endpoint is the collector address as host:port (no scheme), e.g.\n\"localhost:4318\" for http or \"localhost:4317\" for grpc.","type":"string"},"protocol":{"description":"Protocol selects the OTLP transport: \"http\" or \"grpc\".","type":"string"},"sample_rate":{"description":"SampleRate is the head-based trace sampling ratio in [0,1]. Default 0.1.","type":"number"}},"type":"object"},"config.UpdateCheckConfig":{"description":"Update-check settings (Spec 079 FR-012): config-file control of the\nbackground upgrade-awareness checker (internal/updatecheck). nil =\nenabled on the stable channel (existing default behavior). The existing\nenvironment switches keep working and WIN over these keys (FR-014):\nMCPPROXY_DISABLE_AUTO_UPDATE=true force-disables even when\nenabled=true, and MCPPROXY_ALLOW_PRERELEASE_UPDATES=true force-selects\nthe rc channel even when channel=stable.","properties":{"channel":{"description":"Channel selects which releases are offered as updates: \"stable\"\n(default; prereleases never offered) or \"rc\" (prereleases included).\nEmpty resolves to stable. Validated in ValidateDetailed.\n\nNOTE: for a RELEASED build the running binary's own version is\nauthoritative and overrides this field — a stable build is never\noffered an RC (even with channel=rc), and an RC build always tracks the\nrc channel. This field only takes effect on dev/unstamped builds. See\ninternal/updatecheck.Checker.IncludePrereleases.","type":"string"},"enabled":{"description":"Enabled gates all update checking. Tri-state: nil/absent = enabled\n(default true, matching pre-079 behavior). When false, no network\ncheck is performed and no upgrade nudge appears on any surface\n(FR-015) — /api/v1/info omits the update object entirely.","type":"boolean"}},"type":"object"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\"","type":"string"}},"type":"object"},"connect.ConnectResult":{"description":"The full result; its action mirrors the top-level one","properties":{"action":{"description":"\"created\", \"updated\", \"already_exists\", \"removed\", \"not_found\"","type":"string"},"backup_path":{"type":"string"},"client":{"type":"string"},"config_path":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"arguments":{"description":"Tool call arguments","type":"object"},"detection_types":{"description":"List of detection types found","items":{"type":"string"},"type":"array","uniqueItems":false},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP transport session ID (regenerated on every reconnect)","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\", \"rejected\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"},"work_session_id":{"description":"Spec 082: one client, one project, across reconnects","type":"string"}},"type":"object"},"contracts.ActivitySource":{"description":"How activity was triggered: \"mcp\", \"cli\", \"api\"","type":"string","x-enum-varnames":["ActivitySourceMCP","ActivitySourceCLI","ActivitySourceAPI"]},"contracts.ActivitySummaryResponse":{"properties":{"blocked_count":{"description":"Count of blocked activities","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"rejected_count":{"description":"RejectedCount is the number of calls shed by a concurrency limiter before\nthey reached an upstream (spec 093). Counted separately from errors: it is\nproxy backpressure, not an upstream fault, and it is the signal an\noperator right-sizes max_concurrent_requests against.","type":"integer"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"contracts.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeepScanDescriptor":{"description":"DeepScan reports the opt-in \"deep scan\" layer status (Spec 077 US3),\nSEPARATELY from the baseline verdict above. Always emitted on a computed\nsummary — when deep scan is off (the default) it reports enabled=false\nplus any enabled-but-skipped Docker scanners. It never influences Status.","properties":{"available":{"type":"boolean"},"enabled":{"type":"boolean"},"ran":{"type":"boolean"},"scanners_failed":{"items":{"$ref":"#/components/schemas/contracts.DeepScanScannerFailure"},"type":"array","uniqueItems":false},"skipped_scanners":{"description":"SkippedScanners lists Docker scanners the user enabled that are skipped\nbecause security.deep_scan.enabled is false (informational).","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DeepScanScannerFailure":{"properties":{"id":{"type":"string"},"reason":{"type":"string"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostic":{"description":"Spec 044 — structured diagnostic error and stable error code. Both\nare populated when the server is in a failed state and the error\nhas been classified by internal/diagnostics. Healthy servers omit\nthese fields.","properties":{"cause":{"type":"string"},"code":{"type":"string"},"detected_at":{"type":"string"},"docs_url":{"type":"string"},"fix_steps":{"items":{"$ref":"#/components/schemas/contracts.DiagnosticFixStep"},"type":"array","uniqueItems":false},"severity":{"type":"string"},"user_message":{"type":"string"}},"type":"object"},"contracts.DiagnosticFixStep":{"properties":{"command":{"type":"string"},"destructive":{"type":"boolean"},"fixer_key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.EditRegistrySourceRequest":{"properties":{"name":{"description":"new display name","type":"string"},"servers_url":{"description":"explicit servers-collection URL","type":"string"},"url":{"description":"new base/servers https URL","type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.FindingCounts":{"properties":{"dangerous":{"description":"Tool poisoning, active prompt injection","type":"integer"},"info":{"description":"Low-severity CVEs, informational","type":"integer"},"total":{"type":"integer"},"warning":{"description":"Rug pull, supply chain CVEs with exploits","type":"integer"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object","type":"object"},"config_path":{"description":"Path to config file","type":"string"}},"type":"object"},"contracts.GetRegistriesResponse":{"properties":{"registries":{"items":{"$ref":"#/components/schemas/contracts.Registry"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerLogsResponse":{"properties":{"count":{"type":"integer"},"logs":{"items":{"$ref":"#/components/schemas/contracts.LogEntry"},"type":"array","uniqueItems":false},"server_name":{"type":"string"}},"type":"object"},"contracts.GetServerToolCallsResponse":{"properties":{"server_name":{"type":"string"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerToolsResponse":{"properties":{"count":{"type":"integer"},"server_name":{"type":"string"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GetServersResponse":{"properties":{"servers":{"items":{"$ref":"#/components/schemas/contracts.Server"},"type":"array","uniqueItems":false},"stats":{"$ref":"#/components/schemas/contracts.ServerStats"}},"type":"object"},"contracts.GetSessionDetailResponse":{"properties":{"session":{"$ref":"#/components/schemas/contracts.MCPSession"}},"type":"object"},"contracts.GetSessionsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"sessions":{"items":{"$ref":"#/components/schemas/contracts.MCPSession"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetToolCallDetailResponse":{"properties":{"tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"}},"type":"object"},"contracts.GetToolCallsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GlobalToolsResponse":{"properties":{"failed_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"partial":{"type":"boolean"},"stats":{"$ref":"#/components/schemas/contracts.GlobalToolsStats"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GlobalToolsStats":{"properties":{"disabled":{"type":"integer"},"enabled":{"type":"integer"},"pending_approval":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.HealthStatus":{"description":"Unified health status calculated by the backend","properties":{"action":{"description":"Action is the suggested fix action: \"login\", \"restart\", \"enable\", \"approve\", \"view_logs\", \"set_secret\", \"configure\", or \"\" (none)","type":"string"},"admin_state":{"description":"AdminState indicates the admin state: \"enabled\", \"disabled\", or \"quarantined\"","type":"string"},"detail":{"description":"Detail is an optional longer explanation of the status","type":"string"},"level":{"description":"Level indicates the health level: \"healthy\", \"degraded\", or \"unhealthy\"","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"}},"type":"object"},"contracts.InfoEndpoints":{"description":"Available API endpoints","properties":{"http":{"description":"HTTP endpoint address (e.g., \"127.0.0.1:8080\")","type":"string"},"socket":{"description":"Unix socket path (empty if disabled)","type":"string"}},"type":"object"},"contracts.InfoResponse":{"properties":{"endpoints":{"$ref":"#/components/schemas/contracts.InfoEndpoints"},"launched_by":{"description":"LaunchedBy is the durable launch provenance of the running core (Spec\n092 FR-001a): \"tray\" when a tray spawned it, \"installer\" when the macOS\nPKG postinstall did, \"\" when user-launched or unknown. Always present\n(possibly empty) so a tray can distinguish \"old core, not mine\" from\n\"old core I may supersede\".","type":"string"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"pid":{"description":"PID is the operating-system process id of the running core (Spec 092\nFR-002). A tray that merely ATTACHED to a core holds no Process handle\nfor it, so without this there is no mechanism at all to stop a stale\ncore — the consent action would have nothing to act on and could only\nprint instructions. Paired with LaunchedBy it is what lets a newer tray\nsupersede a core an older tray started.","type":"integer"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"update_policy":{"$ref":"#/components/schemas/contracts.UpdatePolicy"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"network_mode":{"type":"string"},"timeout":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationDefaults":{"description":"IsolationDefaults exposes the resolved baseline values that\nwould apply when no per-server override is set. Populated on\nlist/get responses; never consumed on PATCH requests.","properties":{"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"runtime_type":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.LogEntry":{"properties":{"fields":{"type":"object"},"level":{"type":"string"},"message":{"type":"string"},"server":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.MCPSession":{"properties":{"client_name":{"type":"string"},"client_version":{"type":"string"},"end_time":{"type":"string"},"experimental":{"items":{"type":"string"},"type":"array","uniqueItems":false},"has_roots":{"description":"MCP Client Capabilities","type":"boolean"},"has_sampling":{"type":"boolean"},"id":{"type":"string"},"last_activity":{"type":"string"},"start_time":{"type":"string"},"status":{"type":"string"},"tool_call_count":{"type":"integer"},"total_tokens":{"type":"integer"},"work_session_id":{"type":"string"},"workspace_name":{"description":"Workspace / work session (Spec 082). WorkspaceName is the project's\nbasename — the full local path is never exposed. WorkSessionID groups the\nreconnects that make up one stretch of user work.","type":"string"}},"type":"object"},"contracts.MetadataStatus":{"properties":{"authorization_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"error":{"type":"string"},"found":{"type":"boolean"},"url_checked":{"type":"string"}},"type":"object"},"contracts.MissingSecretInfo":{"properties":{"secret_name":{"type":"string"},"used_by":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.NPMPackageInfo":{"properties":{"exists":{"type":"boolean"},"install_cmd":{"type":"string"}},"type":"object"},"contracts.OAuthConfig":{"properties":{"auth_url":{"type":"string"},"client_id":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_port":{"type":"integer"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_expires_at":{"description":"When the OAuth token expires","type":"string"},"token_url":{"type":"string"},"token_valid":{"description":"Whether token is currently valid","type":"boolean"}},"type":"object"},"contracts.OAuthErrorDetails":{"description":"Structured discovery/failure details","properties":{"authorization_server_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"dcr_status":{"$ref":"#/components/schemas/contracts.DCRStatus"},"protected_resource_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"server_url":{"type":"string"}},"type":"object"},"contracts.OAuthFlowError":{"properties":{"correlation_id":{"description":"Flow tracking ID for log correlation","type":"string"},"debug_hint":{"description":"CLI command for log lookup","type":"string"},"details":{"$ref":"#/components/schemas/contracts.OAuthErrorDetails"},"error_code":{"description":"Machine-readable error code (e.g., OAUTH_NO_METADATA)","type":"string"},"error_type":{"description":"Category of OAuth runtime failure","type":"string"},"message":{"description":"Human-readable error description","type":"string"},"request_id":{"description":"HTTP request ID (from PR #237)","type":"string"},"server_name":{"description":"Server that failed OAuth","type":"string"},"success":{"description":"Always false","type":"boolean"},"suggestion":{"description":"Actionable remediation hint","type":"string"}},"type":"object"},"contracts.OAuthIssue":{"properties":{"documentation_url":{"type":"string"},"error":{"type":"string"},"issue":{"type":"string"},"missing_params":{"items":{"type":"string"},"type":"array","uniqueItems":false},"resolution":{"type":"string"},"server_name":{"type":"string"}},"type":"object"},"contracts.OAuthRequirement":{"properties":{"expires_at":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"state":{"type":"string"}},"type":"object"},"contracts.OAuthStartResponse":{"properties":{"auth_url":{"description":"Authorization URL (always included for manual use)","type":"string"},"browser_error":{"description":"Error message if browser launch failed","type":"string"},"browser_opened":{"description":"Whether browser launch succeeded","type":"boolean"},"correlation_id":{"description":"UUID for tracking this flow","type":"string"},"message":{"description":"Human-readable status message","type":"string"},"server_name":{"description":"Name of the server being authenticated","type":"string"},"success":{"description":"Always true for successful start","type":"boolean"}},"type":"object"},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"blocked_count":{"description":"Number of disabled (blocked) tools","type":"integer"},"changed_count":{"description":"Number of tools whose description/schema changed since approval","type":"integer"},"pending_count":{"description":"Number of newly discovered tools awaiting approval","type":"integer"}},"type":"object"},"contracts.RefreshRegistryResponse":{"properties":{"cleared":{"description":"number of cached entries dropped","type":"integer"},"registry_id":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"deep_scan":{"$ref":"#/components/schemas/contracts.DeepScanDescriptor"},"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary (baseline) scan pass — informational only.\nSpec 077 US3 (FR-008/FR-014): Status is derived SOLELY from the\ndeterministic baseline findings; a failed Docker deep scanner no longer\ndowngrades a clean verdict. That failure is surfaced via DeepScan instead.","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"init_timeout":{"description":"InitTimeout mirrors config.ServerConfig.InitTimeout (MCP-3322 / GH #760):\nthe per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override. Serialized as\na duration string (e.g. \"120s\"); nil/omitted means \"inherit the global\ndefault\". Surfaced on the GET path so clients can read back a configured\noverride; PATCH/POST accept it via AddServerRequest.","type":"string"},"isolation":{"$ref":"#/components/schemas/contracts.IsolationConfig"},"isolation_defaults":{"$ref":"#/components/schemas/contracts.IsolationDefaults"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"max_concurrent_requests":{"description":"Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of\nFR-020. Each setting is tri-state: nil (omitted) means \"inherit\nserver_concurrency_defaults\", 0 disables that setting for this server,\npositive overrides it. Surfaced on the GET path so a caller can read back\nwhat it set; PATCH/POST accept them via AddServerRequest. The effective\nconcurrency for a server is additionally bounded by the global aggregate\nlimiter, which is NOT an inheritance source for these fields.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"security_scan":{"$ref":"#/components/schemas/contracts.SecurityScanSummary"},"should_retry":{"type":"boolean"},"source_registry_id":{"description":"MCP-901 — registry provenance of an upstream that was added from a\nregistry. SourceRegistryID names the source registry (empty for\nmanually-configured servers); SourceRegistryProvenance is the trust tag\nrecorded at add time (\"official/trusted\" or \"custom/unverified\"). Both\nare projected from config.ServerConfig so the approval/quarantine view\ncan render an \"added from \u003cregistry\u003e · unverified\" origin badge. Optional\nand omitted when empty — clients that pre-date this treat them as absent.","type":"string"},"source_registry_provenance":{"type":"string"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"trust_mode":{"description":"TrustMode mirrors config.ServerConfig.TrustMode (spec 086): the per-server\ntrust tier (\"auto\"/\"scan\"/\"manual\"). Surfaced on the GET path so clients can\nread back the persisted mode; PATCH/POST accept it via AddServerRequest.\nOmitted when empty (server predates the field / relies on legacy flags).","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"user_logged_out":{"description":"True if user explicitly logged out (prevents auto-reconnection)","type":"boolean"},"working_dir":{"type":"string"}},"type":"object"},"contracts.ServerActionResponse":{"properties":{"action":{"type":"string"},"async":{"type":"boolean"},"server":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ServerStats":{"properties":{"connected_servers":{"type":"integer"},"docker_containers":{"type":"integer"},"quarantined_servers":{"type":"integer"},"token_metrics":{"$ref":"#/components/schemas/contracts.ServerTokenMetrics"},"total_servers":{"type":"integer"},"total_tools":{"type":"integer"}},"type":"object"},"contracts.ServerTokenMetrics":{"properties":{"average_query_result_size":{"description":"Typical retrieve_tools output (tokens)","type":"integer"},"per_server_tool_list_sizes":{"additionalProperties":{"type":"integer"},"description":"Token size per server","type":"object"},"saved_tokens":{"description":"Difference","type":"integer"},"saved_tokens_percentage":{"description":"Percentage saved","type":"number"},"total_server_tool_list_size":{"description":"All upstream tools combined (tokens)","type":"integer"}},"type":"object"},"contracts.SuccessResponse":{"properties":{"data":{"type":"object"},"success":{"type":"boolean"}},"type":"object"},"contracts.TokenMetrics":{"description":"Token usage metrics (nil for older records)","properties":{"encoding":{"description":"Encoding used (e.g., cl100k_base)","type":"string"},"estimated_cost":{"description":"Optional cost estimate","type":"number"},"input_tokens":{"description":"Tokens in the request","type":"integer"},"model":{"description":"Model used for tokenization","type":"string"},"output_tokens":{"description":"Tokens in the response","type":"integer"},"total_tokens":{"description":"Total tokens (input + output)","type":"integer"},"truncated_tokens":{"description":"Tokens removed by truncation","type":"integer"},"was_truncated":{"description":"Whether response was truncated","type":"boolean"}},"type":"object"},"contracts.Tool":{"properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"approval_status":{"type":"string"},"config_denied":{"description":"ConfigDenied is true when the tool is denied by the server's static\nenabled_tools / disabled_tools config. The user cannot override this toggle.","type":"boolean"},"description":{"type":"string"},"disabled":{"description":"Disabled mirrors ToolApprovalRecord.Disabled so per-tool enable state is\navailable without a second round-trip to the approvals endpoint. Absent\nin the JSON when false (default) to keep responses compact.","type":"boolean"},"held_reason":{"description":"HeldReason, HeldVerdict and HeldSignals mirror the same-named fields on\nstorage.ToolApprovalRecord: the offline-scan evidence that made\ntrust_mode: scan hold this tool for review (spec 086 FR-018). HeldSignals\nnames the matched deterministic check ids, e.g.\n\"tpa.TPA-2026-0001.hidden_instruction\", so a reviewer can see WHY the tool\nis held. All three are omitted for tools that are not held by the scan gate\n(including every record written before the field existed).","type":"string"},"held_signals":{"items":{"type":"string"},"type":"array","uniqueItems":false},"held_verdict":{"type":"string"},"last_used":{"type":"string"},"name":{"type":"string"},"schema":{"type":"object"},"server_name":{"type":"string"},"usage":{"type":"integer"}},"type":"object"},"contracts.ToolAnnotation":{"description":"Tool behavior hints snapshot","properties":{"destructiveHint":{"type":"boolean"},"idempotentHint":{"type":"boolean"},"openWorldHint":{"type":"boolean"},"readOnlyHint":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"contracts.ToolCallRecord":{"description":"The new tool call record","properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"arguments":{"description":"Tool arguments","type":"object"},"config_path":{"description":"Active config file path","type":"string"},"duration":{"description":"Duration in nanoseconds","type":"integer"},"error":{"description":"Error message (failure only)","type":"string"},"execution_type":{"description":"\"direct\" or \"code_execution\"","type":"string"},"id":{"description":"Unique identifier","type":"string"},"mcp_client_name":{"description":"MCP client name from InitializeRequest","type":"string"},"mcp_client_version":{"description":"MCP client version","type":"string"},"mcp_session_id":{"description":"MCP session identifier","type":"string"},"metrics":{"$ref":"#/components/schemas/contracts.TokenMetrics"},"parent_call_id":{"description":"Links nested calls to parent code_execution","type":"string"},"request_id":{"description":"Request correlation ID","type":"string"},"response":{"description":"Tool response (success only)","type":"object"},"server_id":{"description":"Server identity hash","type":"string"},"server_name":{"description":"Human-readable server name","type":"string"},"timestamp":{"description":"When the call was made","type":"string"},"tool_name":{"description":"Tool name (without server prefix)","type":"string"}},"type":"object"},"contracts.UpdateInfo":{"description":"Update information (if available)","properties":{"available":{"description":"Whether an update is available","type":"boolean"},"check_error":{"description":"Error message if update check failed","type":"string"},"checked_at":{"description":"When the update check was performed","type":"string"},"install_channel":{"description":"Detected install channel (homebrew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown) — Spec 079 FR-008","type":"string"},"is_prerelease":{"description":"Whether the latest version is a prerelease","type":"boolean"},"latest_version":{"description":"Latest version available (e.g., \"v1.2.3\")","type":"string"},"nudges_suppressed":{"description":"UI surfaces must stay quiet (CI / non-interactive context); machine-readable fields still report the facts — Spec 079 FR-019","type":"boolean"},"release_url":{"description":"URL to the release page","type":"string"},"update_command":{"description":"One-line update command for the channel; only set when an update is available and the channel has one — Spec 079 FR-009","type":"string"}},"type":"object"},"contracts.UpdatePolicy":{"description":"UpdatePolicy is the effective, hot-reloadable update policy (Spec 092\nFR-015). Always present: the ` + "`" + `update` + "`" + ` object above is omitted both when\nupdate checking is disabled AND when no check has produced a result\nyet, so its absence cannot tell a client whether it is allowed to run\nits own (e.g. Sparkle feed) check. This field states the answer.","properties":{"channel":{"description":"Channel is the tracked release channel: \"stable\" or \"rc\".","type":"string"},"enabled":{"description":"Enabled is the effective automatic-check kill switch: update_check.enabled\nwith MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated\n\"Check for Updates\" stays available regardless.","type":"boolean"},"nudges_suppressed":{"description":"NudgesSuppressed asks UI surfaces to stay quiet (CI / non-interactive)\nwhile machine-readable fields keep reporting the facts.","type":"boolean"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.UsageAggregateResponse":{"properties":{"freshness_ms":{"description":"age of the underlying snapshot in ms","type":"integer"},"generated_at":{"type":"string"},"other":{"$ref":"#/components/schemas/contracts.UsageOtherBucket"},"timeline":{"items":{"$ref":"#/components/schemas/contracts.UsageTimeBucket"},"type":"array","uniqueItems":false},"token_source":{"description":"\"bytes\" (size-based proxy, FR-006)","type":"string"},"tokens_saved":{"description":"echoed from ServerTokenMetrics (FR-007)","type":"integer"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"window":{"type":"string"}},"type":"object"},"contracts.UsageOtherBucket":{"description":"present only when the list was truncated to top-N","properties":{"calls":{"type":"integer"},"tools_folded":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageTimeBucket":{"properties":{"calls":{"type":"integer"},"errors":{"type":"integer"},"start":{"type":"string"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageToolStat":{"properties":{"avg_req_bytes":{"description":"null when no sized request calls","type":"integer"},"avg_resp_bytes":{"description":"null when sized_calls == 0 (only legacy 0-byte calls)","type":"integer"},"blocked":{"type":"integer"},"calls":{"type":"integer"},"error_rate":{"type":"number"},"errors":{"type":"integer"},"last_used":{"type":"string"},"p50_ms":{"type":"integer"},"p95_ms":{"type":"integer"},"rejected":{"description":"spec 093: shed by a concurrency limit; never executed, so excluded from calls/latency","type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"init_timeout":{"description":"InitTimeout is the per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override\n(MCP-3322 / GH #760), serialized as a duration string (e.g. \"120s\"). A nil\npointer means \"leave unchanged\" on PATCH; a present value is applied.\nMirrors config.ServerConfig.InitTimeout's *Duration tri-state.","type":"string"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"max_concurrent_requests":{"description":"MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server\nconcurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is\ntri-state: a nil pointer means \"leave unchanged\" on PATCH and \"inherit\nserver_concurrency_defaults\" on create; an explicit 0 disables that\nsetting for this server; a positive value overrides it. Do NOT collapse\nthem to plain values — an omitted field would then silently reset a\nconfigured limit.","type":"integer"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"type":"boolean"},"trust_mode":{"description":"TrustMode is the per-server trust tier (spec 086): \"auto\", \"scan\", or\n\"manual\". Empty means \"leave unchanged\" on PATCH (and inherit the migrated\ndefault on create). A non-empty value is applied to ServerConfig.TrustMode\nand resolved by EffectiveTrustMode (an unrecognized value fails closed to\nmanual). This is the REST seam for changing the trust tier via\nPOST/PATCH /api/v1/servers.","type":"string"},"url":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.CanonicalConfigPath":{"properties":{"description":{"description":"Brief description","type":"string"},"exists":{"description":"Whether the file exists","type":"boolean"},"format":{"description":"Format identifier (e.g., \"claude_desktop\")","type":"string"},"name":{"description":"Display name (e.g., \"Claude Desktop\")","type":"string"},"os":{"description":"Operating system (darwin, windows, linux)","type":"string"},"path":{"description":"Full path to the config file","type":"string"}},"type":"object"},"httpapi.CanonicalConfigPathsResponse":{"properties":{"os":{"description":"Current operating system","type":"string"},"paths":{"description":"List of canonical config paths","items":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPath"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ConnectConflictResponse":{"properties":{"action":{"description":"already_exists | precondition_failed","type":"string"},"data":{"$ref":"#/components/schemas/connect.ConnectResult"},"error":{"description":"Human-readable message","type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"precondition_token":{"description":"PreconditionToken is the opaque token from the preview this write was\nconfirmed against (Spec 091 FR-005). When present, the core rechecks it\nat write time and responds 409 with action \"precondition_failed\" —\nwriting nothing — if the config or the entry MCPProxy would write has\ndrifted since; the caller then re-previews instead of retrying. Absent\nmeans exactly the pre-091 behavior. A replace-classified flow sends this\nTOGETHER with force=true: the token, not the absence of force, is the\noverwrite safety.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.ImportFromPathRequest":{"properties":{"format":{"description":"Optional format hint","type":"string"},"path":{"description":"File path to import from","type":"string"},"rename":{"additionalProperties":{"type":"string"},"description":"Rename maps a server name → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys are matched against\neither the raw source name (OriginalName) or the sanitized name shown\nin the preview (Server.Name); these differ for names that need\nsanitizing (e.g. \"Figma Desktop\" → \"Figma_Desktop\"). Keys not present\nin the imported set are ignored.","type":"object"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportRequest":{"properties":{"content":{"description":"Raw JSON or TOML content","type":"string"},"format":{"description":"Optional format hint","type":"string"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportResponse":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/configimport.FailedServer"},"type":"array","uniqueItems":false},"format":{"type":"string"},"format_name":{"type":"string"},"imported":{"items":{"$ref":"#/components/schemas/httpapi.ImportedServerResponse"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/configimport.SkippedServer"},"type":"array","uniqueItems":false},"summary":{"$ref":"#/components/schemas/configimport.ImportSummary"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportedServerResponse":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (image,\nnetwork_mode, extra_args, working_dir, enabled). A nil pointer\nmeans \"do not touch isolation config\"; an empty-but-present\nobject on PATCH intentionally clears the overrides.","properties":{"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.OnboardingMarkRequest":{"properties":{"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value. The stored enum is wider (Spec 080\nFR-001): a \"skipped\" request for a previously untouched connect step\nis upgraded server-side to \"completed_external\" when the install\nshows positive evidence of an external connection (Spec 080 FR-002).\n\"completed_external\" is NOT accepted from clients — it must never be\npersisted without that server-verified evidence (edge case: \"never\nguess completed_external without positive evidence\").","type":"string"},"engaged":{"description":"Engaged marks the wizard as engaged (completed or explicitly skipped).\nOnce true, the wizard does not auto-show again.","type":"boolean"},"mark_shown":{"description":"MarkShown records the wizard's first display time if not already set.","type":"boolean"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","type":"string"}},"type":"object"},"httpapi.SetActiveProfileRequest":{"properties":{"active_profile":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.UndoConnectRequest":{"properties":{"backup_name":{"description":"BackupName is the bare filename (filepath.Base) of the backup returned as\nbackup_path by the preceding connect — a name, never a path. Undo resolves\nthe full path server-side by joining it with the client's own config\ndirectory, so a client-supplied value can never contribute a directory\ncomponent (traversal is impossible by construction). Empty means the\nconnect created the file (no prior file existed), so undo removes it.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.UpdateFailureRequest":{"properties":{"stage":{"description":"Stage is the failure stage of the update session.","enum":["appcast","download","install","other"],"type":"string"}},"type":"object"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned upstream servers (MCP-2769). It is OFF by\ndefault and deliberately kept out of the AllowedSystemVars default list:\nproxy URLs frequently carry credentials (http://user:pass@proxy), so\nforwarding them to every stdio upstream is a credential-leak risk. When\nenabled, values are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"},"telemetry.FeedbackContext":{"properties":{"arch":{"type":"string"},"connected_server_count":{"type":"integer"},"edition":{"type":"string"},"os":{"type":"string"},"routing_mode":{"type":"string"},"server_count":{"type":"integer"},"version":{"type":"string"}},"type":"object"},"telemetry.FeedbackRequest":{"properties":{"category":{"description":"bug, feature, other","type":"string"},"context":{"$ref":"#/components/schemas/telemetry.FeedbackContext"},"email":{"type":"string"},"message":{"type":"string"}},"type":"object"},"telemetry.FeedbackResponse":{"properties":{"error":{"type":"string"},"issue_url":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"description":"API key authentication via query parameter. Use ?apikey=your-key","in":"query","name":"apikey","type":"apiKey"}}}, "info": {"contact":{"name":"MCPProxy Support","url":"https://github.com/smart-mcp-proxy/mcpproxy-go"},"description":"{{escape .Description}}","license":{"name":"MIT","url":"https://opensource.org/licenses/MIT"},"title":"{{.Title}}","version":"{{.Version}}"}, "externalDocs": {"description":"","url":""}, "paths": {"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Omit arguments, response and metadata except a contextual whitelist (intent.reason, intent.operation_type, decision, reason, client_name, client_version) (default: false). For clients that render summary fields only; has_sensitive_data is still derived before metadata is dropped.","in":"query","name":"exclude_payloads","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to export (1-50000, default 10000)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get usage statistics aggregate","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/config":{"get":{"description":"Retrieves the current MCPProxy configuration including all server definitions, global settings, and runtime parameters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetConfigResponse"}}},"description":"Configuration retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Apply configuration","tags":["config"]}},"/api/v1/config/docker-isolation":{"patch":{"description":"Convenience endpoint to flip ` + "`" + `docker_isolation.enabled` + "`" + ` without resending the full config. Persists to disk via the existing config writer — the file watcher then hot-reloads the change. Returns the new state and whether a restart is required for existing connections to pick it up.","requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"}},"type":"object"}}},"description":"New isolation state","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Isolation toggle applied"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Toggle global Docker isolation","tags":["config"]}},"/api/v1/config/validate":{"post":{"description":"Validates a provided MCPProxy configuration without applying it. Checks for syntax errors, invalid server definitions, conflicting settings, and other configuration issues.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to validate","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ValidateConfigResponse"}}},"description":"Configuration validation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Validation failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Validate configuration","tags":["config"]}},"/api/v1/connect":{"get":{"description":"Returns the connection status for all known MCP client applications.\nEach entry indicates whether the client config file exists and whether\nMCPProxy is currently registered in it.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"List of ClientStatus objects"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List client connection status","tags":["connect"]}},"/api/v1/connect/{client}":{"delete":{"description":"Remove the MCPProxy entry from the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional parameters (server_name)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or entry not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disconnect MCPProxy from a client","tags":["connect"]},"get":{"description":"Resolves one client's status by reading its config file on demand.\nThis is the only Connect endpoint that opens a client config file, so\non macOS it is the sole place an App-Data privacy prompt may legitimately\nappear (scoped to this user action). Resolves access_state to\naccessible|absent|denied|malformed and populates remediation when denied.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get a single client's connection status (on-demand)","tags":["connect"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.\nOptionally accepts precondition_token from a preview (Spec 091): when supplied,\nthe core rechecks the raw pre-write state and the entry it would write, and\nrefuses a drifted write with 409 before taking any backup. The 409 body's\naction discriminates the two conflict kinds: \"precondition_failed\" (stale\npreview — re-preview, do not retry) vs \"already_exists\" (entry present — pass\nforce=true). force=true never rescues a stale token.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters (server_name, force, precondition_token)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectConflictResponse"}}},"description":"Conflict: action=already_exists (use force=true) or action=precondition_failed (preview is stale; re-preview)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Connect MCPProxy to a client","tags":["connect"]}},"/api/v1/connect/{client}/preview":{"get":{"description":"Returns the exact entry a subsequent connect would add to the client's\nconfig — target path, server key, entry name, and entry contents — WITHOUT\nmodifying the file or creating a backup (Spec 078 US1). The embedded API key\nis masked in the payload; contains_api_key flags that a credential is written.\nentry_exists distinguishes a create from an overwrite of a same-named entry.\nReads the config on demand to classify create-vs-overwrite, so on macOS this\nmay raise an App-Data privacy prompt; a denial returns 403 + remediation.\nSpec 091 adds three fields: existing_entry_summary (present only when\nentry_exists — a sanitized, non-secret projection of the entry being replaced:\nits name, type, endpoint with query/userinfo stripped, command, and header and\nenv NAMES, never values); precondition_token (always present — an opaque keyed\ndigest of the raw pre-write state and the pending entry, echoed back on POST\nconnect to detect drift); and connect_refusal (present when the write would\nrefuse regardless of intent, e.g. a non-create-capable client with no config —\ntreat its presence as \"Connect unavailable\").","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Entry name to preview (defaults to mcpproxy); mirror the value passed to POST connect","in":"query","name":"server_name","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectPreview"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview the change a connect would make (no write)","tags":["connect"]}},"/api/v1/connect/{client}/undo":{"post":{"description":"Reverts the connect that produced the named backup (Spec 078 US3):\nrestores the client config byte-for-byte from that backup, or — when\nbackup_name is empty because the connect created the file — deletes the\ncreated file. backup_name is the bare filename of the backup the connect\nreturned (never a path); undo resolves the full path server-side inside\nthe client's own config directory, so a client value cannot escape it.\nRefuses with 409 when the config changed since the connect (undo never\nclobbers later edits; use DELETE /connect/{client} for a surgical entry\nremoval instead). Takes its own safety backup first; its path is returned\nas backup_path in the result.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UndoConnectRequest"}}},"description":"Undo parameters (server_name, backup_name = the bare filename of the backup the preceding connect returned)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult (action restored|deleted)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (e.g. backup_name is a path, or not a backup of this client's config)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or backup no longer exists"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Config changed since connect; undo refused"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Undo a connect, restoring the pre-connect config","tags":["connect"]}},"/api/v1/diagnostics":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/docker/status":{"get":{"description":"Retrieve current Docker availability and recovery status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Docker status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get Docker status","tags":["docker"]}},"/api/v1/doctor":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/feedback":{"post":{"description":"Submit a bug report, feature request, or general feedback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackRequest"}}},"description":"Feedback request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Bad Request"},"429":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyAuth":[]}],"summary":"Submit feedback","tags":["feedback"]}},"/api/v1/index/search":{"get":{"description":"Search across all upstream MCP server tools using BM25 keyword search","parameters":[{"description":"Search query","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Maximum number of results","in":"query","name":"limit","schema":{"default":10,"maximum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchToolsResponse"}}},"description":"Search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing query parameter)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search for tools","tags":["tools"]}},"/api/v1/info":{"get":{"description":"Get essential server metadata including version, web UI URL, endpoint addresses, and update availability\nThis endpoint is designed for tray-core communication and version checking\nUse refresh=true query parameter to force an immediate update check against GitHub\nThe launched_by field reports durable launch provenance (\"tray\", \"installer\", or \"\" for user-launched/unknown)","parameters":[{"description":"Force immediate update check against GitHub","in":"query","name":"refresh","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Server information with optional update info"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server information","tags":["status"]}},"/api/v1/onboarding/mark":{"post":{"description":"Updates wizard engagement and per-step status. Once engaged is\ntrue, the wizard does not auto-show again, even if state regresses.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.OnboardingMarkRequest"}}},"description":"Mark request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Mark onboarding wizard state (Spec 046)","tags":["onboarding"]}},"/api/v1/onboarding/state":{"get":{"description":"Returns the wizard engagement record alongside live predicates\n(whether any client is connected, whether any server is configured),\nplus a derived ShouldShowWizard flag the frontend can rely on.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"OnboardingStateResponse"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get onboarding wizard state and predicates (Spec 046)","tags":["onboarding"]}},"/api/v1/profiles":{"get":{"description":"List all configured profiles with their effective servers and indexed tool count (Profiles v2). A profile scopes tool discovery and calls to a named subset of upstream servers.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Profile list"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Configuration unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List configured profiles","tags":["profiles"]}},"/api/v1/profiles/active":{"get":{"description":"Get the server-level default active profile used by UI surfaces (Web UI / tray). Empty string means \"all servers\". Note: within a live MCP session, the set_profile tool selection takes precedence over this default.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the default active profile","tags":["profiles"]},"put":{"description":"Set the server-level default active profile for UI surfaces. The slug must match a configured profile; pass an empty string to clear. This does not affect live MCP sessions, which use the set_profile tool.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.SetActiveProfileRequest"}}},"description":"Profile slug to activate (empty clears)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request body"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Set the default active profile","tags":["profiles"]}},"/api/v1/registries":{"get":{"description":"Retrieves list of all MCP server registries that can be browsed for discovering and installing new upstream servers. Includes registry metadata, server counts, and API endpoints.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetRegistriesResponse"}}},"description":"Registries retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to list registries"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List available MCP server registries","tags":["registries"]},"post":{"description":"Adds a generic modelcontextprotocol/registry v0.1 https endpoint as a custom registry (MCP-866). The source is always tagged custom/unverified, so every server discovered through it lands quarantined and can never skip quarantine.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddRegistrySourceRequest"}}},"description":"Registry source (https url + optional protocol/id/name)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source added"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/contracts.ErrorResponse"},{"$ref":"#/components/schemas/contracts.ErrorResponse"}]}}},"description":"Forbidden (agent tokens cannot mutate registries)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin | duplicate_registry"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a user-supplied registry source","tags":["registries"]}},"/api/v1/registries/{id}":{"delete":{"description":"Removes a custom/unverified registry previously added via add-source (MCP-1057). Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source removed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove a user-added custom registry source","tags":["registries"]},"put":{"description":"Updates a custom registry previously added via add-source (MCP-1072): name, url, servers-url. Empty fields are left unchanged. Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found; a non-https url yields invalid_registry_url. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.EditRegistrySourceRequest"}}},"description":"Fields to update (name/url/servers_url; empty = unchanged)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required | invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Edit a user-added custom registry source","tags":["registries"]}},"/api/v1/registries/{id}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot add servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove an upstream server","tags":["servers"]},"patch":{"description":"Update specific fields of an existing upstream MCP server configuration.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Fields to update (all optional)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server updated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - no fields or invalid body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/config-to-secret":{"post":{"description":"Atomically reads the real value from the server config, stores it in the OS keyring, and rewrites the config field to ` + "`" + `${keyring:\u003cname\u003e}` + "`" + `. Unblocks the UI's Convert-to-secret affordance for values the API redacts on the read path.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored, config updated with reference"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad scope/key/secret_name, or value is already a reference / empty"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server or key not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver or config update failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Convert a header / env value to a keyring secret","tags":["servers"]}},"/api/v1/servers/{id}/disable":{"post":{"description":"Disable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server disabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/discover-tools":{"post":{"description":"Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool discovery triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot discover tools)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to discover tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Discover tools for a specific server","tags":["servers"]}},"/api/v1/servers/{id}/enable":{"post":{"description":"Enable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server enabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/login":{"post":{"description":"Initiate OAuth authentication flow for a specific upstream MCP server. Returns structured OAuth start response with correlation ID for tracking.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthStartResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthFlowError"}}},"description":"OAuth error (client_id required, DCR failed, etc.)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Trigger OAuth login for server","tags":["servers"]}},"/api/v1/servers/{id}/logout":{"post":{"description":"Clear OAuth authentication token and disconnect a specific upstream MCP server. The server will need to re-authenticate before tools can be used again.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"OAuth logout completed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled or read-only mode)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Clear OAuth token and disconnect server","tags":["servers"]}},"/api/v1/servers/{id}/logs":{"get":{"description":"Retrieve log entries for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Number of log lines to retrieve","in":"query","name":"tail","schema":{"default":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerLogsResponse"}}},"description":"Server logs retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server logs","tags":["servers"]}},"/api/v1/servers/{id}/quarantine":{"post":{"description":"Place a specific upstream MCP server in quarantine to prevent tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server quarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Quarantine a server","tags":["servers"]}},"/api/v1/servers/{id}/refresh":{"post":{"description":"Re-discover and re-index a specific upstream MCP server's tools without changing any security state. Alias of discover-tools, named for the upstream_servers 'refresh' operation; use it to make just-approved tools searchable immediately.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool refresh triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot refresh)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Refresh a server's tools","tags":["servers"]}},"/api/v1/servers/{id}/restart":{"post":{"description":"Restart the connection to a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server restarted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/tool-calls":{"get":{"description":"Retrieves tool call history filtered by upstream server ID. Returns recent tool executions for the specified server including timestamps, arguments, results, and errors. Useful for server-specific debugging and monitoring.","parameters":[{"description":"Upstream server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolCallsResponse"}}},"description":"Server tool calls retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get server tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history for specific server","tags":["tool-calls"]}},"/api/v1/servers/{id}/tools":{"get":{"description":"Retrieve all available tools for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolsResponse"}}},"description":"Server tools retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/block":{"post":{"description":"Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The \"blocked\" field counts tools actually blocked.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Block result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Block (approve+disable) tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/disable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/enable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/unquarantine":{"post":{"description":"Remove a specific upstream MCP server from quarantine to allow tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server unquarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Unquarantine a server","tags":["servers"]}},"/api/v1/sessions":{"get":{"description":"Retrieves paginated list of active and recent MCP client sessions. Each session represents a connection from an MCP client to MCPProxy, tracking initialization time, tool calls, and connection status.","parameters":[{"description":"Maximum number of sessions to return (1-100, default 10)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of sessions to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter by session status","in":"query","name":"status","schema":{"enum":["active","closed"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid status filter"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get sessions"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get active MCP sessions","tags":["sessions"]}},"/api/v1/sessions/{id}":{"get":{"description":"Retrieves detailed information about a specific MCP client session including initialization parameters, connection status, tool call count, and activity timestamps.","parameters":[{"description":"Session ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionDetailResponse"}}},"description":"Session details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get MCP session details by ID","tags":["sessions"]}},"/api/v1/stats/tokens":{"get":{"description":"Retrieve token savings statistics across all servers and sessions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Token statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get token savings statistics","tags":["stats"]}},"/api/v1/status":{"get":{"description":"Get comprehensive server status including running state, listen address, upstream statistics, and timestamp","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server status","tags":["status"]}},"/api/v1/telemetry/payload":{"get":{"description":"Render the exact JSON heartbeat payload that mcpproxy would next send to the telemetry endpoint, without making a network call. Counters in the payload reflect the current in-memory state. Spec 042.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Telemetry heartbeat payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/telemetry/update-failure":{"post":{"description":"Records one terminal update-session failure, identified only by its\nstage (appcast, download, install, other). The body carries no error\ntext, URL, or version — the stage is the only value transmitted.\nReturns 204 both when the occurrence was durably persisted and when\ntelemetry is inactive at event time (config opt-out, environment\nopt-out, CI, or dev build), in which case nothing is recorded.\nCallers cannot and need not distinguish the two.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UpdateFailureRequest"}}},"description":"Update failure stage","required":true},"responses":{"204":{"description":"Accepted (recorded, or a deliberate no-op while telemetry is inactive)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Malformed body, unknown field, trailing value, or stage outside the closed set"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Persistence failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Record a desktop auto-update failure occurrence (Spec 095)","tags":["telemetry"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}}, diff --git a/oas/swagger.yaml b/oas/swagger.yaml index 3644ce80..c1687d54 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -62,6 +62,10 @@ components: check_server_repo: description: Repository detection settings type: boolean + code_execution_max_parallel: + description: 'Default concurrency for call_tools() batches (1-32, default: + 8)' + type: integer code_execution_max_tool_calls: description: 'Max tool calls per execution (0 = unlimited, default: 0)' type: integer diff --git a/specs/096-batched-call-tools/tasks.md b/specs/096-batched-call-tools/tasks.md index 29e9ebca..c5e12036 100644 --- a/specs/096-batched-call-tools/tasks.md +++ b/specs/096-batched-call-tools/tasks.md @@ -10,11 +10,11 @@ No setup tasks — existing Go project, no new dependencies. ## Phase 2: Foundational (blocking all user stories) -- [ ] T001 Promote the bare sandbox error-code string literals (`INVALID_ARGS`, `ACCESS_DENIED`, `PERMISSION_DENIED`, `UPSTREAM_ERROR`) to constants in internal/jsruntime/errors.go and use them in internal/jsruntime/runtime.go makeCallToolFunction; existing tests must stay green (pure refactor, byte-identical error payloads). -- [ ] T002 Extract the per-element gates (allow-list, agent-scope, permission-tier — R5 gates 4–6) from makeCallToolFunction into a pure helper on ExecutionContext returning a plain error map (nil = allowed) in internal/jsruntime/runtime.go, side effects (budget read, updateMaxPermissionLevel) staying at call sites; add a parity test in internal/jsruntime/batch_test.go asserting the helper reproduces the lone-call codes and check order (budget-first overall, per data-model.md). -- [ ] T003 Add unexported `ctx context.Context` to ExecutionContext, assigned from the existing timeoutCtx right after its creation in Execute (internal/jsruntime/runtime.go:~181); unit test in internal/jsruntime/batch_test.go asserts the field is non-nil during execution and cancelled once Execute returns; lone call_tool continues to use context.Background() (assert unchanged behavior via existing tests). -- [ ] T004 [P] Add `code_execution_max_parallel` config field: struct field beside siblings (internal/config/config.go:~418), DefaultConfig 8 (~1700), range validation 1–32 (~2142), post-load defaulting absent/0→8 (~2421); table-driven tests in internal/config/config_test.go (default, explicit, invalid range, zero) written first and observed failing. -- [ ] T005 Fix the two hot-reload breaks (R6): add a code_execution changed-field clause to DetectConfigChanges in internal/runtime/config_hotreload.go covering all four CodeExecution* fields; switch the resolveCodeExecutionDefaults call site in internal/server/mcp_code_execution.go to read via p.currentConfig(); add `MaxParallel int` to jsruntime.ExecutionOptions and extend resolveCodeExecutionDefaults (unset/0 → config value); failing-first tests in internal/runtime/config_hotreload_test.go (edit touching only code_execution_max_parallel produces a change event) and internal/server/code_execution_options_test.go (MaxParallel default resolution). +- [x] T001 Promote the bare sandbox error-code string literals (`INVALID_ARGS`, `ACCESS_DENIED`, `PERMISSION_DENIED`, `UPSTREAM_ERROR`) to constants in internal/jsruntime/errors.go and use them in internal/jsruntime/runtime.go makeCallToolFunction; existing tests must stay green (pure refactor, byte-identical error payloads). +- [x] T002 Extract the per-element gates (allow-list, agent-scope, permission-tier — R5 gates 4–6) from makeCallToolFunction into a pure helper on ExecutionContext returning a plain error map (nil = allowed) in internal/jsruntime/runtime.go, side effects (budget read, updateMaxPermissionLevel) staying at call sites; add a parity test in internal/jsruntime/batch_test.go asserting the helper reproduces the lone-call codes and check order (budget-first overall, per data-model.md). +- [x] T003 Add unexported `ctx context.Context` to ExecutionContext, assigned from the existing timeoutCtx right after its creation in Execute (internal/jsruntime/runtime.go:~181); unit test in internal/jsruntime/batch_test.go asserts the field is non-nil during execution and cancelled once Execute returns; lone call_tool continues to use context.Background() (assert unchanged behavior via existing tests). +- [x] T004 [P] Add `code_execution_max_parallel` config field: struct field beside siblings (internal/config/config.go:~418), DefaultConfig 8 (~1700), range validation 1–32 (~2142), post-load defaulting absent/0→8 (~2421); table-driven tests in internal/config/config_test.go (default, explicit, invalid range, zero) written first and observed failing. +- [x] T005 Fix the two hot-reload breaks (R6): add a code_execution changed-field clause to DetectConfigChanges in internal/runtime/config_hotreload.go covering all four CodeExecution* fields; switch the resolveCodeExecutionDefaults call site in internal/server/mcp_code_execution.go to read via p.currentConfig(); add `MaxParallel int` to jsruntime.ExecutionOptions and extend resolveCodeExecutionDefaults (unset/0 → config value); failing-first tests in internal/runtime/config_hotreload_test.go (edit touching only code_execution_max_parallel produces a change event) and internal/server/code_execution_options_test.go (MaxParallel default resolution). **Checkpoint**: `go test -race ./internal/jsruntime/... ./internal/config/... ./internal/runtime/... ./internal/server/ -run 'CodeExec|Config'` green; no behavior change observable to existing callers. @@ -23,9 +23,9 @@ No setup tasks — existing Go project, no new dependencies. **Goal**: `call_tools()` exists, runs elements concurrently, returns ordered per-slot envelopes. **Independent test**: stub ToolCaller with fixed 50ms latency; batch of 10 completes < 35% of serial time with identical results. -- [ ] T006 [US1] Write failing batch-core tests in internal/jsruntime/batch_test.go using a latency/concurrency-recording stub ToolCaller: (a) 10-element batch returns 10 slots in input order with lone-call-identical envelopes; (b) wall-clock < 35% of the serial equivalent (SC-001 shape); (c) `call_tools([])` returns `[]` with zero budget consumed; (d) single-element batch byte-equivalent to lone call_tool for the same request; (e) results are plain JSON wire shapes (reuse tool_result_test.go assertions). -- [ ] T007 [US1] Implement makeCallToolsFunction + private runBatch in internal/jsruntime/runtime.go per plan Design Outline steps 2–5: script-goroutine parse/validate (arity, dense array, element shape, options integer 1–32, cap 100 → single INVALID_ARGS envelope naming first offending index); input-order pre-dispatch pass (budget-first check order via T002 helper, script-thread-local dispatch counter); prefilled-closed-channel worker pool of min(max_parallel, dispatchable) honoring ec.ctx; workers produce plain slot values + ToolCallRecords into index-owned cells; unconditional WaitGroup join; input-order record append + updateMaxPermissionLevel + single vm.ToValue. Make T006 green. -- [ ] T008 [US1] Bind `call_tools` in Execute beside call_tool (internal/jsruntime/runtime.go:~171) and add it (+ `options.max_parallel`) to BOTH code_execution description strings — internal/server/mcp.go:~913/934 and internal/server/mcp_routing.go buildCodeExecutionTool:~506/540 — with identical wording; test in internal/server/code_execution_options_test.go (or a new surfaces test) asserting both descriptions mention call_tools, written first. +- [x] T006 [US1] Write failing batch-core tests in internal/jsruntime/batch_test.go using a latency/concurrency-recording stub ToolCaller: (a) 10-element batch returns 10 slots in input order with lone-call-identical envelopes; (b) wall-clock < 35% of the serial equivalent (SC-001 shape); (c) `call_tools([])` returns `[]` with zero budget consumed; (d) single-element batch byte-equivalent to lone call_tool for the same request; (e) results are plain JSON wire shapes (reuse tool_result_test.go assertions). +- [x] T007 [US1] Implement makeCallToolsFunction + private runBatch in internal/jsruntime/runtime.go per plan Design Outline steps 2–5: script-goroutine parse/validate (arity, dense array, element shape, options integer 1–32, cap 100 → single INVALID_ARGS envelope naming first offending index); input-order pre-dispatch pass (budget-first check order via T002 helper, script-thread-local dispatch counter); prefilled-closed-channel worker pool of min(max_parallel, dispatchable) honoring ec.ctx; workers produce plain slot values + ToolCallRecords into index-owned cells; unconditional WaitGroup join; input-order record append + updateMaxPermissionLevel + single vm.ToValue. Make T006 green. +- [x] T008 [US1] Bind `call_tools` in Execute beside call_tool (internal/jsruntime/runtime.go:~171) and add it (+ `options.max_parallel`) to BOTH code_execution description strings — internal/server/mcp.go:~913/934 and internal/server/mcp_routing.go buildCodeExecutionTool:~506/540 — with identical wording; test in internal/server/code_execution_options_test.go (or a new surfaces test) asserting both descriptions mention call_tools, written first. **Checkpoint**: quickstart.md example works against a stub; US1 acceptance scenarios pass. @@ -34,8 +34,8 @@ No setup tasks — existing Go project, no new dependencies. **Goal**: per-slot error isolation and whole-call validation semantics pinned. **Independent test**: mixed-failure batch resolves 100% of slots. -- [ ] T009 [US2] Write failing error-isolation tests in internal/jsruntime/batch_test.go: (a) batch of 5 with element 3 hitting an upstream error → 4 ok slots + 1 UPSTREAM_ERROR slot, order intact; (b) scope-violating element → SERVER_NOT_ALLOWED/ACCESS_DENIED slot per lone-call parity, siblings unaffected; (c) over-budget tail elements → MAX_TOOL_CALLS_EXCEEDED slots, not dispatched (stub records dispatch count); (d) non-serializable result → SERIALIZATION_ERROR slot; (e) malformed calls (non-array, bad element shape, non-object args, sparse hole, options non-object, fractional/out-of-range max_parallel, >100 elements) → single INVALID_ARGS envelope naming the first offending index, stub records zero dispatches; (f) args omitted defaults to {}. -- [ ] T010 [US2] Fix any behavior T009 exposes in internal/jsruntime/runtime.go until green; no test may be weakened to pass. +- [x] T009 [US2] Write failing error-isolation tests in internal/jsruntime/batch_test.go: (a) batch of 5 with element 3 hitting an upstream error → 4 ok slots + 1 UPSTREAM_ERROR slot, order intact; (b) scope-violating element → SERVER_NOT_ALLOWED/ACCESS_DENIED slot per lone-call parity, siblings unaffected; (c) over-budget tail elements → MAX_TOOL_CALLS_EXCEEDED slots, not dispatched (stub records dispatch count); (d) non-serializable result → SERIALIZATION_ERROR slot; (e) malformed calls (non-array, bad element shape, non-object args, sparse hole, options non-object, fractional/out-of-range max_parallel, >100 elements) → single INVALID_ARGS envelope naming the first offending index, stub records zero dispatches; (f) args omitted defaults to {}. +- [x] T010 [US2] Fix any behavior T009 exposes in internal/jsruntime/runtime.go until green; no test may be weakened to pass. **Checkpoint**: US2 acceptance scenarios + SC-003 pass. @@ -44,16 +44,16 @@ No setup tasks — existing Go project, no new dependencies. **Goal**: max_parallel bound + override, cancellation discipline, Spec-093 subordination. **Independent test**: concurrency high-water mark obeys the bound; timeout cancels workers with a full join. -- [ ] T011 [US3] Write failing concurrency-bound tests in internal/jsruntime/batch_test.go: (a) batch of 10 with max_parallel 3 → stub high-water mark ≤ 3, all complete; (b) per-call override beats ExecutionOptions.MaxParallel; (c) ExecutionOptions.MaxParallel (config default path) governs when no override; (d) built-in 8 when neither set. -- [ ] T012 [US3] Write failing cancellation tests in internal/jsruntime/batch_test.go and make them green: execution timeout mid-batch (stub blocks until ctx cancel) → Execute returns TIMEOUT; stub asserts every in-flight ctx was cancelled; instrument runBatch (test hook or stub-side sync) to prove the join completed and exactly one ToolCallRecord exists per dispatched element; whole file must pass under -race. -- [ ] T013 [US3] Concurrent-safety test at the server seam in internal/server/mcp_code_execution_test.go: N goroutines invoking upstreamToolCaller.CallTool concurrently (as batch workers will) → getToolCalls returns N records, -race clean; reference (not re-test) Spec 093's admission coverage in the test comment, and assert the ctx passed by workers reaches the ToolCaller (stub captures it). +- [x] T011 [US3] Write failing concurrency-bound tests in internal/jsruntime/batch_test.go: (a) batch of 10 with max_parallel 3 → stub high-water mark ≤ 3, all complete; (b) per-call override beats ExecutionOptions.MaxParallel; (c) ExecutionOptions.MaxParallel (config default path) governs when no override; (d) built-in 8 when neither set. +- [x] T012 [US3] Write failing cancellation tests in internal/jsruntime/batch_test.go and make them green: execution timeout mid-batch (stub blocks until ctx cancel) → Execute returns TIMEOUT; stub asserts every in-flight ctx was cancelled; instrument runBatch (test hook or stub-side sync) to prove the join completed and exactly one ToolCallRecord exists per dispatched element; whole file must pass under -race. +- [x] T013 [US3] Concurrent-safety test at the server seam in internal/server/mcp_code_execution_test.go: N goroutines invoking upstreamToolCaller.CallTool concurrently (as batch workers will) → getToolCalls returns N records, -race clean; reference (not re-test) Spec 093's admission coverage in the test comment, and assert the ctx passed by workers reaches the ToolCaller (stub captures it). **Checkpoint**: US3 acceptance scenarios + SC-004 shape pass; `go test -race ./internal/jsruntime/...` green. ## Phase 6: Polish & Cross-Cutting -- [ ] T014 [P] Regenerate OpenAPI (`make swagger`) for the new config field and add `code_execution_max_parallel` (number, min 1, max 32) to the code-execution section of frontend/src/views/settings/fields.ts; frontend unit test only if an existing pattern covers sibling fields (tests live in frontend/tests/unit/*.spec.ts). -- [ ] T015 [P] Documentation: add call_tools() + max_parallel to docs/configuration.md, docs/configuration/config-file.md, docs/features/code-execution.md, docs/code_execution/{overview,api-reference,troubleshooting,cookbook}.md, including the queue-vs-shed note from research R3 (queue_size headroom warning). +- [x] T014 [P] Regenerate OpenAPI (`make swagger`) for the new config field and add `code_execution_max_parallel` (number, min 1, max 32) to the code-execution section of frontend/src/views/settings/fields.ts; frontend unit test only if an existing pattern covers sibling fields (tests live in frontend/tests/unit/*.spec.ts). +- [x] T015 [P] Documentation: add call_tools() + max_parallel to docs/configuration.md, docs/configuration/config-file.md, docs/features/code-execution.md, docs/code_execution/{overview,api-reference,troubleshooting,cookbook}.md, including the queue-vs-shed note from research R3 (queue_size headroom warning). - [ ] T016 Full verification: `go build ./cmd/mcpproxy` + `-tags server`; `go test -race -count=1 ./internal/...`; `go test -tags server ./internal/serveredition/... -race`; `/opt/homebrew/bin/golangci-lint run --config .github/.golangci.yml ./...`; `./scripts/test-api-e2e.sh`; revert e2e-config churn; run the quickstart example end-to-end via REST code/exec against the e2e everything-server as a smoke check. ## Dependencies From 44be83a90631556b161e60b3d9ed6283158f950d Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 14 Aug 2026 15:14:08 +0300 Subject: [PATCH 08/10] fix(096): pool_size honestly reported as restart-required; settings help states override semantics Related #987 Review findings: code_execution_pool_size is sized once at server construction and never resized in-process, so it now reports its own changed field with requires_restart instead of riding the hot-applied code_execution group; the settings help text now says a script can override (not merely lower) batch concurrency per call within 1-32. --- frontend/src/views/settings/fields.ts | 2 +- internal/runtime/config_hotreload.go | 19 ++++++++++++++----- internal/runtime/config_hotreload_test.go | 14 +++++++++++++- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/frontend/src/views/settings/fields.ts b/frontend/src/views/settings/fields.ts index 53149fa9..c8cf1634 100644 --- a/frontend/src/views/settings/fields.ts +++ b/frontend/src/views/settings/fields.ts @@ -325,7 +325,7 @@ export const ADVANCED_ACCORDIONS: SettingsAccordion[] = [ { key: 'code_execution_timeout_ms', label: 'Max run time per execution (ms)', control: 'number', min: 1, max: 600000 }, { key: 'code_execution_max_tool_calls', label: 'Max tool calls per execution', help: '0 = unlimited.', control: 'number', min: 0 }, { key: 'code_execution_pool_size', label: 'JavaScript runtime pool size', help: 'How many sandboxes run concurrently.', control: 'number', min: 1, max: 100 }, - { key: 'code_execution_max_parallel', label: 'Parallel calls per call_tools() batch', help: 'Default concurrency for batched tool calls; a script can lower it per call.', control: 'number', min: 1, max: 32 }, + { key: 'code_execution_max_parallel', label: 'Parallel calls per call_tools() batch', help: 'Default concurrency for batched tool calls; a script can override it per call (1-32).', control: 'number', min: 1, max: 32 }, ], }, { diff --git a/internal/runtime/config_hotreload.go b/internal/runtime/config_hotreload.go index 460c9d5a..942cf8ff 100644 --- a/internal/runtime/config_hotreload.go +++ b/internal/runtime/config_hotreload.go @@ -194,17 +194,26 @@ func DetectConfigChanges(oldCfg, newCfg *config.Config) *ConfigApplyResult { // Code execution settings (Spec 096 FR-004 — hot-reloadable). The // code_execution handler resolves timeout / max_tool_calls / // max_parallel from the LIVE snapshot (p.currentConfig()) on every - // execution, and the runtime pool is re-sized when it is rebuilt, so a - // lone edit here must be reported as a change instead of being swallowed - // as "no changes detected". EnableCodeExecution is deliberately absent: - // toggling it changes the registered tool set, which is a restart concern. + // execution, so a lone edit here must be reported as a change instead of + // being swallowed as "no changes detected". EnableCodeExecution is + // deliberately absent: toggling it changes the registered tool set, + // which is a restart concern. if oldCfg.CodeExecutionTimeoutMs != newCfg.CodeExecutionTimeoutMs || oldCfg.CodeExecutionMaxToolCalls != newCfg.CodeExecutionMaxToolCalls || - oldCfg.CodeExecutionPoolSize != newCfg.CodeExecutionPoolSize || oldCfg.CodeExecutionMaxParallel != newCfg.CodeExecutionMaxParallel { result.ChangedFields = append(result.ChangedFields, "code_execution") } + // The JS runtime pool is sized once at server construction and never + // resized in-process, so unlike its siblings pool_size cannot apply hot. + if oldCfg.CodeExecutionPoolSize != newCfg.CodeExecutionPoolSize { + result.ChangedFields = append(result.ChangedFields, "code_execution_pool_size") + result.RequiresRestart = true + if result.RestartReason == "" { + result.RestartReason = "code_execution_pool_size is sized at startup - requires restart" + } + } + // Logging configuration (can be hot-reloaded) if !reflect.DeepEqual(oldCfg.Logging, newCfg.Logging) { result.ChangedFields = append(result.ChangedFields, "logging") diff --git a/internal/runtime/config_hotreload_test.go b/internal/runtime/config_hotreload_test.go index 70024afb..0e870375 100644 --- a/internal/runtime/config_hotreload_test.go +++ b/internal/runtime/config_hotreload_test.go @@ -707,7 +707,6 @@ func TestDetectConfigChanges_CodeExecution(t *testing.T) { "code_execution_max_parallel": func(c *config.Config) { c.CodeExecutionMaxParallel = 16 }, "code_execution_timeout_ms": func(c *config.Config) { c.CodeExecutionTimeoutMs = 60000 }, "code_execution_max_tool_calls": func(c *config.Config) { c.CodeExecutionMaxToolCalls = 5 }, - "code_execution_pool_size": func(c *config.Config) { c.CodeExecutionPoolSize = 20 }, } for name, mutate := range changes { @@ -719,9 +718,22 @@ func TestDetectConfigChanges_CodeExecution(t *testing.T) { }) } + t.Run("code_execution_pool_size requires a restart", func(t *testing.T) { + // The JS runtime pool is sized once at server construction and never + // resized in-process; claiming hot application would leave the old + // concurrency cap silently in force. + result := DetectConfigChanges(mk(nil), mk(func(c *config.Config) { c.CodeExecutionPoolSize = 20 })) + require.True(t, result.Success) + assert.Contains(t, result.ChangedFields, "code_execution_pool_size") + assert.NotContains(t, result.ChangedFields, "code_execution") + assert.True(t, result.RequiresRestart) + assert.Contains(t, result.RestartReason, "code_execution_pool_size") + }) + t.Run("unchanged settings are not reported", func(t *testing.T) { result := DetectConfigChanges(mk(nil), mk(nil)) require.True(t, result.Success) assert.NotContains(t, result.ChangedFields, "code_execution") + assert.NotContains(t, result.ChangedFields, "code_execution_pool_size") }) } From f686edcc7b241a67bb55c80ea1d6cf071e707a09 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 14 Aug 2026 17:00:09 +0300 Subject: [PATCH 09/10] docs(096): warn that the execute() test seam races if read post-timeout without synchronizing Related #987 --- internal/jsruntime/runtime.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/jsruntime/runtime.go b/internal/jsruntime/runtime.go index 0099c293..50f49c2a 100644 --- a/internal/jsruntime/runtime.go +++ b/internal/jsruntime/runtime.go @@ -154,6 +154,12 @@ func Execute(ctx context.Context, caller ToolCaller, code string, opts Execution // execute is Execute plus the execution context it ran, which tests inspect for // state the Result does not carry (recorded tool calls, the worker context). +// +// After a timeout return the abandoned script goroutine may still append to +// the returned context's ToolCalls (lone call and batch alike — there is no +// vm.Interrupt). A test that reads the context after a timeout MUST first +// synchronize with the script goroutine (e.g. via stub-side signalling, as the +// cancellation tests do) or it races. func execute(ctx context.Context, caller ToolCaller, code string, opts ExecutionOptions) (*Result, *ExecutionContext) { // Generate execution ID if not provided if opts.ExecutionID == "" { From 51ee14b94fc4dd8f243c006896e782103b71a179 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Fri, 14 Aug 2026 18:04:27 +0300 Subject: [PATCH 10/10] ci: raise the Windows unit-test timeout to Go's default 10m The Unix step has no explicit -timeout (Go default 10m per package); the Windows step capped at 5m, which internal/runtime and internal/server now exceed on slow runners as the suites have grown. Two consecutive Windows failures on this branch were pure 'panic: test timed out after 5m0s' with different tests holding the bag each time. --- .github/workflows/unit-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 9a834341..5b0090fe 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -123,7 +123,7 @@ jobs: - name: Run unit tests (Windows) if: matrix.os == 'windows-latest' shell: pwsh - run: go test -v -race -timeout 5m '-coverprofile=coverage.out' -covermode=atomic '-skip=E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint' ./... + run: go test -v -race -timeout 10m '-coverprofile=coverage.out' -covermode=atomic '-skip=E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint' ./... - name: Run unit tests (Unix) if: matrix.os != 'windows-latest'