diff --git a/.gitattributes b/.gitattributes index aa9ee1954..b7a8890aa 100644 --- a/.gitattributes +++ b/.gitattributes @@ -24,6 +24,9 @@ internal/server/testdata/**/*.golden.json text eol=lf # Without this Windows checks out CRLF and all three # TestToolsListSnapshot_MatchesMergeBaseGoldens surfaces fail on \r alone. internal/server/testdata/toolslist_goldens/*.json text eol=lf +# The frozen baselines (pre099/, pre105/) live one level down, where a single +# `*` does not reach. +internal/server/testdata/toolslist_goldens/**/*.json text eol=lf # Self-contained verification/QA reports embed base64 PNG screenshots, so a # single file is multiple MB of "HTML". They are point-in-time artifacts, not diff --git a/ROADMAP.md b/ROADMAP.md index a0d997a8d..4920fbbe6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -890,7 +890,7 @@ graph LR | Web UI + macOS app UX audit | In progress | P0 | — | | | | Release qualification gate (auto-QA matrix blocks the tag) | In progress | P0 | — | [081-release-qa-gate](./specs/081-release-qa-gate/) | | | Action log / transparency — info at a glance | In progress | P1 | — | | | -| Agent-token scope hardening: every MCP request authorized by its own scope (spec 105) | In progress | P1 | 12/109 (11%) | [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | | +| Agent-token scope hardening: every MCP request authorized by its own scope (spec 105) | In progress | P1 | 18/109 (17%) | [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | | | Token-efficiency benchmark: measured savings, published results | In progress | P1 | 62/64 (97%) | [103-token-bench](./specs/103-token-bench/) | | | Telemetry identity & data quality (machine_id + CI-filter hardening) | In progress | P1 | — | | | | Telemetry v7: honest funnel + churn instrumentation | In progress | P1 | — | [080-telemetry-v7-churn](./specs/080-telemetry-v7-churn/) | | @@ -1036,6 +1036,6 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [102-schema-deferred](./specs/102-schema-deferred/) | `shipped` | 89/89 (100%) | | [103-token-bench](./specs/103-token-bench/) | `shipped` | 62/64 (97%) | | [104-auto-routing-mode](./specs/104-auto-routing-mode/) | — | — | -| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `in-flight` | 12/109 (11%) | +| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `in-flight` | 18/109 (17%) | | [106-security-residual-fixes](./specs/106-security-residual-fixes/) | `shipped` | 18/19 (95%) | | [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `in-flight` | 100/126 (79%) | diff --git a/cmd/mcpproxy/code_cmd.go b/cmd/mcpproxy/code_cmd.go index c81bce3bb..e15a1b2e7 100644 --- a/cmd/mcpproxy/code_cmd.go +++ b/cmd/mcpproxy/code_cmd.go @@ -607,8 +607,10 @@ func outputResult(result *cliclient.CodeExecResult) error { func outputResultFromMCP(result *mcp.CallToolResult) error { // A tool ERROR is plain text, not the execution envelope — and for a stored // script that text is the recovery path: naming one that does not exist - // answers with the available names (FR-004). Parsing it as JSON and giving - // up ("unexpected result format") threw that away. + // answers with the available names (FR-004) because the CLI authenticates + // with the admin API key (an agent token would get the non-disclosing + // form, Spec 105 FR-012). Parsing it as JSON and giving up ("unexpected + // result format") threw that away. if result.IsError { for _, content := range result.Content { if textContent, ok := mcp.AsTextContent(content); ok { diff --git a/docs/code_execution/api-reference.md b/docs/code_execution/api-reference.md index da4f9b7b3..589d4c13e 100644 --- a/docs/code_execution/api-reference.md +++ b/docs/code_execution/api-reference.md @@ -588,11 +588,14 @@ executed source under `code` and additionally carry `script: ""`. | Situation | Message (abbreviated) | |-----------|-----------------------| | Both or neither of `code` / `script` | `Provide exactly one of 'code' (inline source) or 'script' (the name of a script stored in the 'scripts' directory next to mcpproxy's config file) — not both, not neither.` | -| Unknown name | `stored script "X" not found in . Available scripts (N): a, b, c …` | -| No scripts at all | `stored script "X" not found: no stored scripts in (create X.js or X.ts there)` | +| Unknown name (administrator) | `stored script "X" not found in . Available scripts (N): a, b, c …` | +| No scripts at all (administrator) | `stored script "X" not found: no stored scripts in (create X.js or X.ts there)` | +| Unknown name ([agent token](https://docs.mcpproxy.app/features/agent-tokens/), any scope) | `stored script "X" not found (the stored-script listing is available to administrators only; an agent-token caller must already know the script name)` — identical for an empty and a populated directory; the listing is administrator-only | | Invalid name | `invalid script name "…": character "/" is not allowed …` | -| Both extensions present | `stored script "X" is ambiguous: /X.js and /X.ts both exist — remove one` | -| Empty / oversized / unreadable / non-regular | `stored script "X" () is oversized: scripts are limited to 262144 bytes` | +| Both extensions present (administrator) | `stored script "X" is ambiguous: /X.js and /X.ts both exist — remove one` | +| Both extensions present (agent token) | `stored script "X" is ambiguous: both a .js and a .ts file exist — ask an administrator to remove one` — no host path | +| Empty / oversized / unreadable / non-regular (administrator) | `stored script "X" () is oversized: scripts are limited to 262144 bytes` | +| Empty / oversized / unreadable / non-regular (agent token) | `stored script "X" is oversized: scripts are limited to 262144 bytes` — the reason stays, the host path and any raw OS error are withheld | | `language` contradicts the extension | `stored script "X" is a .ts file (typescript) but language "javascript" was requested …` | The not-found error **is** the MCP discovery mechanism (FR-004): it lists the @@ -633,9 +636,9 @@ never re-sends a request that cannot succeed: | Situation | Status | `error.code` | |-----------|--------|--------------| | `enable_code_execution` is `false` | 403 | `FEATURE_DISABLED` | -| Unknown script name (carries the available names) | 404 | `SCRIPT_NOT_FOUND` | +| Unknown script name (carries the available names for an administrator; an agent token gets the non-disclosing message) | 404 | `SCRIPT_NOT_FOUND` | | Invalid script name | 400 | `INVALID_SCRIPT_NAME` | -| Ambiguous, empty, oversized, unreadable or non-regular | 400 | `SCRIPT_UNUSABLE` | +| Ambiguous, empty, oversized, unreadable or non-regular (an agent token gets the path-free message) | 400 | `SCRIPT_UNUSABLE` | | `language` contradicts the extension | 400 | `INVALID_LANGUAGE` | | Execution fault (pool, storage, internal) | 500 | `EXECUTION_FAILED` | @@ -649,7 +652,12 @@ switching the feature off also stops stored scripts from being read from disk. ### REST: `GET /api/v1/code/scripts` Read-only listing of the stored scripts, using the same API-key auth as the rest -of `/api/v1` (`X-API-Key` header or `?apikey=`): +of `/api/v1` (`X-API-Key` header or `?apikey=`). **Administrator-only**: the +admin API key (and the tray over the local socket) get the listing; an +[agent token](https://docs.mcpproxy.app/features/agent-tokens/#what-a-scoped-token-cannot-learn) +— whatever its server scope — is refused with `403` and a body that names +nothing about the directory, because this listing is exactly the enumeration +the missing-script error withholds from a scoped caller: ```bash curl -H "X-API-Key: $MCPPROXY_API_KEY" http://127.0.0.1:8080/api/v1/code/scripts @@ -682,6 +690,10 @@ curl -H "X-API-Key: $MCPPROXY_API_KEY" http://127.0.0.1:8080/api/v1/code/scripts An absent or empty directory returns an empty `scripts` list, not an error. Statuses are advisory — the tool re-checks at invocation time. +```json +{"success": false, "error": "Agent tokens cannot list stored scripts (the stored-script listing is available to administrators only)"} +``` + **There is no write surface.** No endpoint, tool, or CLI verb creates, updates, or deletes a script; the filesystem is the sole authoring interface. diff --git a/docs/code_execution/cookbook.md b/docs/code_execution/cookbook.md index 9cf4e97c5..9a8d2df54 100644 --- a/docs/code_execution/cookbook.md +++ b/docs/code_execution/cookbook.md @@ -138,10 +138,16 @@ Things to know when converting a recipe: - **Edit by atomic replace** (write a temp file, `mv` it over) and the next invocation runs the new content — no daemon restart, so the authoring loop is still "edit, rerun". -- **Discovery** is the not‑found error: naming a script that does not exist - returns the available names (first 20 alphabetically, plus the total), so an - agent never needs the list out of band. `mcpproxy code scripts list` shows the - full set, including `ambiguous` and `invalid` entries. +- **Discovery is administrator‑only**: for an administrator (admin API key, + tray, in‑process caller) naming a script that does not exist returns the + available names (first 20 alphabetically, plus the total). An + [agent token](https://docs.mcpproxy.app/features/agent-tokens/) must already + know the name — its not‑found error lists nothing, so hand the agent the + script names out of band (or in its custom instructions). `mcpproxy code + scripts list` shows the full set, including `ambiguous` and `invalid` entries. +- **Scripts are published content**: whoever can run one sees whatever it + returns without an upstream call; only its `call_tool()` calls are + scope‑checked. Keep server names and secrets out of script source. - **Read‑only surface**: nothing writes scripts for you — no tool, no endpoint, no CLI verb. Authoring is the filesystem, deliberately. diff --git a/docs/code_execution/overview.md b/docs/code_execution/overview.md index d736b276a..bd6f52463 100644 --- a/docs/code_execution/overview.md +++ b/docs/code_execution/overview.md @@ -361,7 +361,9 @@ JS mv "$tmp" ~/.mcpproxy/scripts/fetch-prs.js # atomic within the same filesystem ``` -Adding or deleting a file is reflected on the next invocation or listing. +Adding or deleting a file is reflected on the next invocation or listing +(for an agent token on Linux, after the next index refresh — see +[Discovering script names](#discovering-script-names)). Editing a script **in place** while it is being invoked is the one unsupported case: the run gets whatever the read returned (validated, but unspecified). @@ -376,17 +378,93 @@ mcpproxy code scripts list -o json # {"dir": "...", "scripts": [{"name","paths" curl -H "X-API-Key: $KEY" http://127.0.0.1:8080/api/v1/code/scripts ``` +Both are administrator views: the REST listing answers only the admin API key +(or the tray over the local socket) and refuses an agent token with `403`. + MCP clients do not get a listing tool — registrations are static, so an embedded -list would go stale. Discovery is **error-driven** instead: invoking a name that -does not exist returns an error listing the first 20 available names -alphabetically plus the total, so an agent recovers the current name set from a -single failed call. +list would go stale. For **administrators** (the admin API key, the tray over the +local socket, an in-process caller — and, under the default +`require_mcp_auth: false`, an unauthenticated `/mcp` client, which the proxy +treats as an administrator for backward compatibility) discovery is +**error-driven** instead: +invoking a name that does not exist returns an error listing the first 20 +available names alphabetically plus the total, so the current name set is +recovered from a single failed call. ```text Cannot execute stored script: stored script "fetch-pr" not found in /Users/me/.mcpproxy/scripts. Available scripts (3): daily-report, fetch-prs, triage ``` +**Enumeration is administrator-only.** An +[agent token](https://docs.mcpproxy.app/features/agent-tokens/) — whatever its +server scope, even `--servers "*"` — must already know the script name. Its +not-found error names neither the other stored scripts, nor how many there are, +nor the directory, and it is byte-for-byte the same whether the directory is +empty or full, so a failed call cannot be used to probe what is stored — and +the proxy does not read the directory on its behalf at all — it probes the +requested name's two candidate files and nothing else — so the refusal's cost +does not grow with the number of stored scripts. (On Linux and the BSDs, which +have no single-entry call reporting how a name is spelled on disk, the scoped +resolver answers ONLY from an exact-name index of the directory that matches +its CURRENT state: built when the daemon starts, validated by one stat of +the directory per request, and refreshed by a background rebuild when that +stat finds the directory changed. No request lists the directory, cold or +warm. On Linux/BSD, every step of that per-request check — the stat, the +candidate probe, the open, and the re-check after the open — is bound to +the SAME retained directory descriptor rather than resolving the path +again for each one, so a symlink or bind mount retargeted mid-request +cannot make different steps see different directories. A call landing while +that rebuild is merely scheduled or in flight is +refused exactly like one against a directory the index has never seen — +never answered from what the index held before the change — so a rename +under a scoped caller's feet cannot have that caller's own probe fold onto +whatever now occupies the old name. Beyond that, the index only ever +*authorizes* from a stamp that is provably SETTLED — old enough (about two +seconds, the coarsest directory-timestamp granularity MCPProxy has to assume) +that no filesystem write could still land on it unseen — so a matching +generation is not, by itself, enough to trust a hit; a directory whose +timestamp is younger than that refuses every scoped call, hit or miss alike, +the same fail-closed way. A script added to, or renamed within, the +directory becomes callable by agent tokens once the index has both +refreshed AND settled — typically milliseconds for the refresh, up to about +two seconds to settle; retry a call refused in that window — while +administrators see the change immediately. Every platform — Linux, the +BSDs, darwin and Windows alike — answers from this same index, so a name +that is merely a case-variant of a stored one and a name that is not stored +at all cost the same: both are plain index misses. macOS/darwin adds one +extra, belt-and-suspenders check on top: after the winning candidate is +opened, MCPProxy re-reads its on-disk spelling from the open descriptor +itself (`F_GETPATH`) and compares it to what was requested, so a +case-rename racing the open is caught on the descriptor that would actually +have been read. On Windows every step — probing a candidate, opening it, +listing the directory to refresh the index — is performed relative to ONE +directory handle retained for the whole call (`NtCreateFile` with the +handle as the open's root), so a rename or a reparse point planted on the +directory itself or an ancestor cannot redirect where a "relative" open +actually lands; the post-open check then only needs to confirm the opened +descriptor's own base name (`GetFinalPathNameByHandle`), since the parent +is already structurally guaranteed by the handle-relative open itself. The +refusal itself: + +```text +Cannot execute stored script: stored script "fetch-pr" not found (the stored-script +listing is available to administrators only; an agent-token caller must already +know the script name) +``` + +The same rule covers the other refusals: an ambiguous, empty, oversized or +unreadable script is reported to an agent token by name and reason only — no +host path, no raw OS error — while an administrator sees the full path. + +Stored scripts are operator-published content: any caller allowed to run +`code_execution` can run a script it knows the name of and receive whatever the +script returns without an upstream call, while every `call_tool()` the script +makes is still checked against the caller's server scope and permission tier. +Do not put server names, credentials or other secrets in a script's source or +its constant return values — see the +[agent-token invariant](https://docs.mcpproxy.app/features/agent-tokens/#what-a-scoped-token-cannot-learn). + ### No write path Nothing in mcpproxy creates, edits, or deletes a stored script: no MCP tool, no diff --git a/docs/code_execution/troubleshooting.md b/docs/code_execution/troubleshooting.md index ab748a0ce..145c9b3cc 100644 --- a/docs/code_execution/troubleshooting.md +++ b/docs/code_execution/troubleshooting.md @@ -613,15 +613,24 @@ Or, with an empty/absent directory: Cannot execute stored script: stored script "fetch-pr" not found: no stored scripts in /Users/me/.mcpproxy/scripts (create fetch-pr.js or fetch-pr.ts there) ``` +Or, when the caller is an [agent token](https://docs.mcpproxy.app/features/agent-tokens/) +rather than an administrator — the listing, the count and the directory are +withheld, and the message is the same whether the directory is empty or full: +``` +Cannot execute stored script: stored script "fetch-pr" not found (the stored-script listing is available to administrators only; an agent-token caller must already know the script name) +``` + **Cause**: No `.js` / `.ts` in the scripts directory. Usually a typo (names are **case-sensitive**), a file that is not a script (uppercase or other extension: `.JS`, `.mjs`, `.jsx` are ignored), or the wrong directory — the scripts directory follows the **active config file**, not `--data-dir`. -**Solution**: This error *is* the discovery mechanism — it lists the first 20 -available names alphabetically plus the total, so an MCP client can recover the -name set from the failed call. For the full picture, including where the daemon -looked: +**Solution**: For an administrator this error *is* the discovery mechanism — it +lists the first 20 available names alphabetically plus the total, so the name +set is recovered from the failed call. An agent token gets no listing: give the +agent the script names out of band (or in its custom instructions) and check +them against the administrator's view. For the full picture, including where +the daemon looked: ```bash mcpproxy code scripts list mcpproxy code scripts list --config /etc/mcpproxy/mcp_config.json # a non-default config @@ -629,7 +638,43 @@ mcpproxy code scripts list --config /etc/mcpproxy/mcp_config.json # a non-defa If the directory in the message is not the one you authored in, start the daemon with the config file you meant (`mcpproxy serve --config …`) — with `~/.mcpproxy/mcp_config.json` the scripts live in `~/.mcpproxy/scripts/`. -mcpproxy never creates the directory itself; `mkdir -p` it. + +**Case-insensitive filesystems** (the default macOS and Windows volumes; on +Linux a Docker Desktop bind mount from a macOS or Windows host, vfat, an ext4 +`casefold` directory): the on-disk spelling still decides, for every caller. +`FETCH-PR.JS` or `Fetch-pr.js` is not the script `fetch-pr` even where the +filesystem would open it under that name — the daemon verifies the stored +spelling before running anything, so the administrator's listing, the +administrator's call and an agent-token call all agree. Every platform — +Linux, the BSDs, macOS/darwin and Windows — answers an agent-token call +ONLY from an exact-name index of the directory that matches its CURRENT +state — built at daemon start, validated once per call, refreshed in the +background when the directory changes — so no call lists the directory, +whatever name is asked for; a differently-cased name and one that is not +stored at all cost exactly the same, and the refusal body is unchanged. +Every step of one call's own check — the stat, the candidate probe, the +open and the re-check after it — is bound to a single directory descriptor +(or, on Windows, handle) retained for that call, never a fresh resolution +of the path per step, so a symlink, bind mount or reparse point retargeted +mid-call cannot make two of those steps disagree about which directory they +are looking at. A call landing while that refresh is +scheduled or in flight is refused exactly as one against a directory never +seen before, never served from what the index held a moment ago — a rename +cannot have a scoped caller's own probe fold onto whatever now occupies the +old name. Even once refreshed, the index only authorizes a hit once its +directory timestamp is provably SETTLED (old enough — about two seconds — +that a write could not still be landing on the same coarse tick): a script +you have just added or renamed is callable by agent tokens only after the +index has both refreshed AND settled — retry a call refused in that +window, up to about two seconds — while administrators see the change at +once; mcpproxy never creates the directory itself, `mkdir -p` it. macOS +adds one extra, belt-and-suspenders check on top: after the open, it +re-reads the opened descriptor's own stored spelling (`F_GETPATH`) and +refuses on any mismatch. Windows performs the probe, the open and the +background listing all relative to the SAME retained directory handle +(`NtCreateFile`), so the post-open check only needs to confirm the opened +handle's own name (`GetFinalPathNameByHandle`) rather than re-walking a +path that a retargeted reparse point could have redirected. --- @@ -735,7 +780,9 @@ running in the sandbox has no filesystem access either. **Solution**: Author scripts with your normal filesystem tooling (editor, `scp`, configuration management). `GET /api/v1/code/scripts` and `mcpproxy code scripts -list` are read-only views of the result. +list` are read-only, administrator-only views of the result (an +[agent token](https://docs.mcpproxy.app/features/agent-tokens/) is refused with +`403`). --- diff --git a/docs/configuration.md b/docs/configuration.md index 95f4acfba..cced48278 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1255,6 +1255,8 @@ You can edit this from the Web UI under **Settings → Advanced → MCP server i **Note:** Applied at startup / on the next client connect — editing this value does not hot-reload into already-connected MCP sessions. +**Warning:** the text is operator-published content, returned verbatim to **every** client that initializes — including [agent tokens](https://docs.mcpproxy.app/features/agent-tokens/#what-a-scoped-token-cannot-learn) scoped to a subset of servers. Do not put server names, hostnames, credentials or other secrets in it. + --- ## Tool-Level Quarantine diff --git a/docs/features/agent-tokens.md b/docs/features/agent-tokens.md index efddedc84..2c7f48fe7 100644 --- a/docs/features/agent-tokens.md +++ b/docs/features/agent-tokens.md @@ -234,6 +234,11 @@ Server scoping is enforced at three levels: masked, so this is a credential *inventory* rather than a disclosure, but it names the secrets of servers the caller may not enumerate. A strictly narrower view of the document `GET /api/v1/config` already denies. + - `GET /api/v1/code/scripts` — the stored-script listing (every name, its + host path and the scripts directory) is exactly the enumeration the + missing-script error withholds from a scoped caller, so the door is + closed on the REST surface too (see + [What a scoped token cannot learn](#what-a-scoped-token-cannot-learn)). **Withheld rather than denied.** `GET /api/v1/status` stays open — agents legitimately poll it for liveness — but its `activation` block is omitted for @@ -340,6 +345,125 @@ Server scoping is enforced at three levels: re-fetches and re-stamps it. The no-eviction guarantee applies to entries written after the upgrade. +### What a scoped token cannot learn + +**Invariant.** No proxy-produced response to an agent-token caller — a tool +result, a refusal, a listing, a count, a suggestion, a notification, a cached +page or a log line — names, counts or otherwise discloses a server, tool, +prompt, profile or stored resource outside the caller's effective scope, and an +out-of-scope resource is refused exactly as a nonexistent one would be. +Administrators (the admin API key, the tray over the local socket, and native +stdio) keep every capability they have today; the exceptions where an +administrator's answer deliberately differs from a token's are named and tested +one by one. + +> **Rollout status.** This invariant is being landed surface by surface as the +> agent-scope hardening series (Spec 105) merges; each release's notes list the +> surfaces it closes. The rules on this page that are stated as present-tense +> guarantees — the stored-script rules below, the REST doors listed above and +> the `read_cache` rule — are enforced by the version that documents them. Until +> the series is complete, a listing or suggestion on a surface not yet covered +> can still name an out-of-scope resource; treat that as a known gap, not a +> configuration mistake. + +> **Who counts as an administrator.** The admin API key, the tray over the +> local socket, native stdio, an in-process caller — and, under the default +> `require_mcp_auth: false`, an **unauthenticated** `/mcp` client, which the +> proxy has always treated as an administrator for backward compatibility. Only +> an agent token is a scoped caller; if unauthenticated clients must not see +> administrator answers, set +> [`require_mcp_auth: true`](https://docs.mcpproxy.app/configuration/) so every +> `/mcp` request carries a key or a token. + +**Covered surfaces.** The invariant holds for agent-token requests on every +HTTP MCP surface — `/mcp`, `/mcp/all`, `/mcp/call`, `/mcp/code`, +`/mcp/p/` and the trailing-slash alias of each (see +[Routing Modes](https://docs.mcpproxy.app/features/routing-modes/)) — and on +the REST doors listed above. Native stdio is local-administrator-only and is +not a token surface. + +**Retained, documented effects.** Some shared resources are fleet-wide by +construction and this invariant does not change them; a hidden server can still +*affect* what an authorized caller experiences, without being *named*: + +- **Display-name collision admission on `/mcp/all`** — two servers exposing the + same display name collide fleet-wide, so a hidden server can withhold an + authorized entry from the direct listing. +- **Prompt collision rule and the global prompt cap** — evaluated over the whole + fleet. +- **Fleet-wide `list_changed` notifications** on the fixed surfaces — a hidden + server's change still emits the (content-free) notification. +- **Shared call limiter** — the proxy-wide concurrency limit is global, so calls + held on a hidden server can make a call to an authorized server fail with the + existing "proxy-wide limit saturated" response. +- **Cross-server security-scan admission** — under `trust_mode: scan`, a + same-name near-identical tool on a hidden server can hold an authorized + server's newly added tool pending as a shadowing finding, changing that + tool's discovery and dispatch outcome (see + [Security Quarantine](https://docs.mcpproxy.app/features/security-quarantine/)). +- **Shared prompt-refresh deadline** — prompts are collected under one + fleet-wide deadline, so a slow hidden server can exhaust it before an + authorized server's prompts are collected. +- **Shared log rotation and retention** — attribution filters what a token can + read back, not what history survives rotation. + +**Operator-published content — keep secrets out.** Two kinds of operator-authored +content are published to every caller by design and sit outside the invariant: + +1. **Custom initialization `instructions`** (the `instructions` key in the + [config file](https://docs.mcpproxy.app/configuration/)) are returned + verbatim to every client that initializes, scoped or not. +2. **Stored code-execution scripts** — any caller allowed to run + `code_execution` can run a script it knows the name of and receive whatever + the script returns without an upstream call. What the invariant *does* + cover: a missing-script error never enumerates the other script names, the + script count or the scripts directory to an agent-token caller (the refusal + is identical for an empty and a populated directory, and the directory is + never read on the caller's behalf: on Linux and the BSDs the scoped + resolver answers ONLY from an exact-name index of the directory that + matches its CURRENT state — built when the daemon starts and refreshed by + a background rebuild whenever a call finds the directory changed — so no + call ever lists it, whatever name is asked for and however many scripts + are stored, and every step one call takes (the directory stat, the + candidate probe, the open, and the re-check after it) is bound to a + single directory descriptor retained for that call rather than a fresh + resolution of the path each time; a call that lands while that rebuild is + scheduled or in + flight is refused once, exactly like a call against a directory it has + never seen, rather than answered from what the index held a moment ago — + an entry the index once listed under an earlier spelling must never still + authorize it after a rename. The index authorizes a hit only once its + directory timestamp is provably settled — old enough (roughly two + seconds, the coarsest directory-timestamp granularity assumed) that no + write could still be landing on the same tick unseen — so a matching + generation alone is not enough; a script added to (or renamed within) the + directory becomes callable by agent tokens only after the index has both + refreshed and settled — retry a call refused in that window, up to + roughly two seconds — while administrators see the change immediately. + Linux, the BSDs, darwin and Windows all answer from this same index, so + a differently-cased name and one that is not stored at all cost the + same — both are plain misses. darwin re-checks the opened descriptor's + on-disk spelling as an extra, belt-and-suspenders proof; Windows performs + every step of a call — probing, opening, and the background listing that + refreshes the index — relative to ONE directory handle retained for the + whole call, so a rename or a reparse point cannot redirect where a + "relative" open lands, and the post-open check need only confirm the + opened descriptor's own name; an ambiguous or unusable script is + reported by + name and reason only, without its host path or a raw OS error; + the REST listing `GET /api/v1/code/scripts` answers an agent token with + `403`; administrators keep today's listing and paths; and every + `call_tool()` a script makes is checked against the caller's server scope + and permission tier — a hidden server is refused exactly as a nonexistent + one. The published `code_execution` definition says so — enumeration is + administrator-only and an agent-token caller must already know the script + name. See + [Stored scripts](https://docs.mcpproxy.app/code_execution/overview/#stored-scripts). + +Do **not** place server names, hostnames, credentials, tokens or any other +secret in either — a scoped agent can read them, and a script's constant return +value is as public as its name. + ## Administrative Operations Are Admin-Only Agent tokens can **discover and call** tools (within their scope and permission tier) but can **never administer servers**. Server-mutating operations require the admin API key (or a local tray/socket connection, which is admin by OS-level auth) on **every** surface — the MCP tools and the REST API share one policy (`internal/auth`), so an agent cannot do over HTTP what it is blocked from doing over MCP. diff --git a/internal/codescripts/codescripts.go b/internal/codescripts/codescripts.go index b2f047ee9..a2b89fa1b 100644 --- a/internal/codescripts/codescripts.go +++ b/internal/codescripts/codescripts.go @@ -75,6 +75,55 @@ const ( // (Unix maps the kernel's no-follow rejection onto it). var errNonRegular = errors.New("not a regular file") +// scopedOpener opens the winning candidate for reading. nil means "use the +// package's own openScriptFile" — the administrator's path-based no-follow +// open (round 13: also Windows's own storedspellings_probe_windows.go, +// whose own reparse-hardened openScriptFile in open_windows.go is already +// authoritative — see scopedVerifier). Non-nil on every unix platform +// (round 11 MUST-FIX for Linux/BSD, round 13 for darwin joining the same +// design), where it is bound to the single retained directory descriptor +// the request's own candidates() call opened, so the exact entry that was +// probed is the exact entry that gets opened — never a fresh, independent +// resolution of the path. +type scopedOpener func(path string) (*os.File, error) + +// scopedVerifier re-proves, on the descriptor openScriptFile or a +// scopedOpener actually opened, that nothing was swapped between the probe +// and the open. nil only for the administrator, whose directory-based +// decision has nothing to recheck against. +type scopedVerifier func(f *os.File, want string) error + +// scopedCloser releases whatever per-request resource a candidates() +// implementation opened (round 11 MUST-FIX: the retained directory +// descriptor on every unix platform, round 13 darwin included; a directory +// handle on Windows) — nil when there is nothing to release (the +// administrator alone). resolve defers it immediately after calling +// candidates(), so it always runs exactly once, whether or not a candidate +// was ultimately opened. +type scopedCloser func() + +// errIndexGenerationChanged is what a post-open verifyUnchanged closure +// returns when the scripts directory's generation moved between the index +// lookup that produced a hit and this open (round 8 MUST-FIX, the +// lookup→open race): resolve treats it as an ordinary not-found, never as an +// unreadable-directory error, so it discloses nothing beyond the caller's +// own requested name. Every unix platform's index (Linux/BSD since round 8, +// darwin since round 13) can return this; Windows has no directory +// generation to recheck and never returns it. +var errIndexGenerationChanged = errors.New("codescripts: scripts directory changed between the index lookup and the open") + +// errSpellingUnproven is what a spelling proof beyond the generation +// recheck returns when the OPENED descriptor's stored spelling (round 9 +// MUST-FIX) could not be proven to match the requested name — a mismatch (a +// case-rename or replacement landed between the pre-open probe and the +// open) or a failure of the proof call itself; resolve treats either the +// same as errIndexGenerationChanged, as an ordinary not-found. Returned by +// darwin's F_GETPATH belt-and-suspenders check (extraVerifyOpened, +// entryname_darwin.go, round 13) on top of its own generation recheck, and +// by Windows's full-path proof (storedspellings_probe_windows.go), which has +// no generation to recheck at all. +var errSpellingUnproven = errors.New("codescripts: the opened file's stored spelling could not be proven to match the requested name") + // Entry is one listed script (FR-007). Paths holds the single source file, or // both candidates when the name is ambiguous. type Entry struct { @@ -97,14 +146,40 @@ func (e *InvalidNameError) Error() string { // NotFoundError reports a name with no script file behind it, carrying the // available names so the caller can recover in one round trip (FR-004). +// +// The enumeration is administrator-only (Spec 105 FR-012): a scoped caller +// receives the same error with Undisclosed set, whose text names neither +// the other scripts, their count nor the directory — see NonDisclosing(). type NotFoundError struct { Name string Dir string Available []string // first MaxErrorNames ok names, alphabetical Total int // total ok scripts in the directory + + // Undisclosed marks the agent-token form of the error: the listing is + // withheld and the message is independent of the directory's contents, + // so a failed call cannot serve as an oracle for what is stored. + Undisclosed bool +} + +// nonDisclosingNotFoundFormat is the agent-token refusal text. It carries only +// the caller's own requested name and must never depend on the directory's +// contents (Spec 105 FR-012: byte-equal for an empty and a populated +// directory). +const nonDisclosingNotFoundFormat = "stored script %q not found (the stored-script listing is available to administrators only; an agent-token caller must already know the script name)" + +// NonDisclosing returns a copy of the error stripped of everything that +// discloses the directory's contents — names, count and path — for delivery +// to a scoped (agent-token) caller. The typed identity is preserved, so the +// REST surface still classifies it as SCRIPT_NOT_FOUND. +func (e *NotFoundError) NonDisclosing() *NotFoundError { + return &NotFoundError{Name: e.Name, Undisclosed: true} } func (e *NotFoundError) Error() string { + if e.Undisclosed { + return fmt.Sprintf(nonDisclosingNotFoundFormat, e.Name) + } if e.Total == 0 { return fmt.Sprintf("stored script %q not found: no stored scripts in %s (create %s%s or %s%s there)", e.Name, e.Dir, e.Name, extJS, e.Name, extTS) @@ -118,26 +193,66 @@ func (e *NotFoundError) Error() string { } // AmbiguousError reports a name backed by both a .js and a .ts file. +// +// The paths are host filesystem locations (they reveal the config directory), +// so the scoped form withholds them — see NonDisclosing(). type AmbiguousError struct { Name string Paths []string + + // Undisclosed marks the agent-token form: the message names the caller's + // own script and the reason only, never a host path (Spec 105 FR-012). + Undisclosed bool +} + +// NonDisclosing returns a copy stripped of the host paths, for delivery to a +// scoped (agent-token) caller. The typed identity is preserved, so the REST +// surface still classifies it as SCRIPT_UNUSABLE. +func (e *AmbiguousError) NonDisclosing() *AmbiguousError { + return &AmbiguousError{Name: e.Name, Undisclosed: true} } func (e *AmbiguousError) Error() string { + if e.Undisclosed { + return fmt.Sprintf("stored script %q is ambiguous: both a %s and a %s file exist — ask an administrator to remove one", + e.Name, extJS, extTS) + } return fmt.Sprintf("stored script %q is ambiguous: %s both exist — remove one", e.Name, strings.Join(e.Paths, " and ")) } // InvalidError reports a script file that exists but cannot be executed. +// +// Path is a host filesystem location and Detail is frequently a raw OS error +// carrying another one, so the scoped form withholds both — see +// NonDisclosing(). type InvalidError struct { Name string Path string Reason string Detail string + + // Undisclosed marks the agent-token form: the message names the caller's + // own script and the reason only — no path, no OS error (Spec 105 FR-012). + Undisclosed bool +} + +// NonDisclosing returns a copy stripped of the host path and the raw detail, +// for delivery to a scoped (agent-token) caller. The typed identity and the +// reason are preserved, so the REST surface still classifies it as +// SCRIPT_UNUSABLE and the caller still learns what is wrong with its own +// script. +func (e *InvalidError) NonDisclosing() *InvalidError { + return &InvalidError{Name: e.Name, Reason: e.Reason, Undisclosed: true} } func (e *InvalidError) Error() string { - msg := fmt.Sprintf("stored script %q (%s) is %s", e.Name, e.Path, e.Reason) + var msg string + if e.Undisclosed { + msg = fmt.Sprintf("stored script %q is %s", e.Name, e.Reason) + } else { + msg = fmt.Sprintf("stored script %q (%s) is %s", e.Name, e.Path, e.Reason) + } switch e.Reason { case ReasonOversized: msg += fmt.Sprintf(": scripts are limited to %d bytes", MaxSizeBytes) @@ -152,14 +267,41 @@ func (e *InvalidError) Error() string { // LanguageMismatchError reports an explicit `language` that contradicts the // script's extension (the extension is authoritative). +// +// Extension and Derived are host filesystem facts about the script (its real +// extension is what a directory listing would show), so — like AmbiguousError +// and InvalidError — the scoped form withholds them: see NonDisclosing(). Every +// other refusal `resolve` can return (NotFoundError, AmbiguousError, +// InvalidError) already threads the `disclose` flag through to its own +// NonDisclosing() form; this type was the one omission, always returning the +// full administrator detail regardless of caller kind. type LanguageMismatchError struct { Name string Extension string Requested string Derived string + + // Undisclosed marks the agent-token form: the message names the caller's + // own requested language (its own input, not host information) but + // withholds the script's actual extension and derived language — host + // filesystem facts a directory listing would show (Spec 105 FR-012). + Undisclosed bool +} + +// NonDisclosing returns a copy stripped of the extension/derived language +// details, for delivery to a scoped (agent-token) caller. The typed identity +// is preserved, so the REST surface still classifies it as INVALID_LANGUAGE — +// same as AmbiguousError/InvalidError keeping their own distinct classification +// as SCRIPT_UNUSABLE rather than being folded into SCRIPT_NOT_FOUND. +func (e *LanguageMismatchError) NonDisclosing() *LanguageMismatchError { + return &LanguageMismatchError{Name: e.Name, Requested: e.Requested, Undisclosed: true} } func (e *LanguageMismatchError) Error() string { + if e.Undisclosed { + return fmt.Sprintf("stored script %q does not accept requested language %q — omit 'language' and let it be derived automatically", + e.Name, e.Requested) + } return fmt.Sprintf("stored script %q is a %s file (%s) but language %q was requested — omit 'language' or set it to %q", e.Name, e.Extension, e.Derived, e.Requested, e.Derived) } @@ -214,68 +356,158 @@ func DeriveLanguage(name, ext, explicitLanguage string) (string, error) { } // Resolve reads the stored script `name` from scriptsDir and returns its -// source together with the language derived from its extension. +// source together with the language derived from its extension. This is the +// ADMINISTRATOR form: a not-found error carries the directory's listing +// (FR-004) and every other refusal names the host path it is about. // // Order matters: the name is validated BEFORE any filesystem call (SC-003), -// then the directory decides which candidates exist, then the surviving -// candidate is opened with the platform's no-follow idiom and read through a -// bounded reader. Exactly one open and one read per call — no cache, no re-read. +// then the directory decides which candidates exist (pre-105 behaviour, kept +// verbatim: a directory the administrator cannot list is a refusal, SC-005), +// then the surviving candidate is opened with the platform's no-follow idiom +// and read through a bounded reader. Exactly one open and one read per call — +// no cache, no re-read. func Resolve(scriptsDir, name, explicitLanguage string) (source []byte, language string, err error) { + return resolve(scriptsDir, name, explicitLanguage, true) +} + +// ResolveScoped is Resolve for a scoped (agent-token) caller — Spec 105 +// FR-012. It opens and reads the script exactly as Resolve does, but decides +// its candidates by a constant-cost path probe instead of the directory +// listing, and every refusal it returns is already the non-disclosing form: a +// not-found error is built WITHOUT listing the directory — neither the +// discovery listing nor a directory read on the way to the miss; the two +// candidate names are probed and nothing else, so the refusal's cost does not +// grow with what is stored and does not depend on the name asked for +// (probeCandidates) — and the ambiguous / invalid forms carry the caller's +// own name and the reason but no host path and no raw OS error. Typed +// identities are the same, so the REST classifier does not tell the two +// callers apart. The probe is the scoped resolver's alone: the administrator +// path keeps its directory-based decision (SC-005), so a directory that is +// searchable but not listable still refuses administrators as it always did. +func ResolveScoped(scriptsDir, name, explicitLanguage string) (source []byte, language string, err error) { + return resolve(scriptsDir, name, explicitLanguage, false) +} + +// resolve is the shared body of Resolve and ResolveScoped; disclose selects +// the administrator (true) or the scoped (false) refusal forms. +func resolve(scriptsDir, name, explicitLanguage string, disclose bool) (source []byte, language string, err error) { if err := ValidateName(name); err != nil { return nil, "", err } + notFound := func() error { return notFoundErrorFor(scriptsDir, name, disclose) } + invalid := func(path, reason, detail string) error { + e := &InvalidError{Name: name, Path: path, Reason: reason, Detail: detail} + if !disclose { + return e.NonDisclosing() + } + return e + } + // An empty scripts dir would make filepath.Join produce a bare relative // path resolved against the process CWD — never that. No authority means // no scripts. if scriptsDir == "" { - return nil, "", newNotFoundError(scriptsDir, name) + return nil, "", notFound() } - found, err := candidatesFor(scriptsDir, name) + candidates := candidatesFor + if !disclose { + candidates = probeCandidates + } + found, open, verifyUnchanged, closeSession, err := candidates(scriptsDir, name) + // Round 11 MUST-FIX: whatever per-request resource candidates() opened + // to decide found (a retained directory descriptor on Linux/BSD, a + // directory handle on Windows) is released exactly once here, however + // resolve returns below — a miss, an ambiguous name, a successful read, + // or any refusal in between. + if closeSession != nil { + defer closeSession() + } if err != nil { if errors.Is(err, fs.ErrNotExist) { - return nil, "", newNotFoundError(scriptsDir, name) + return nil, "", notFound() } - return nil, "", &InvalidError{Name: name, Path: scriptsDir, Reason: ReasonUnreadable, Detail: err.Error()} + return nil, "", invalid(scriptsDir, ReasonUnreadable, err.Error()) } switch len(found) { case 0: - return nil, "", newNotFoundError(scriptsDir, name) + return nil, "", notFound() case 1: default: - return nil, "", &AmbiguousError{Name: name, Paths: found} + ambiguous := &AmbiguousError{Name: name, Paths: found} + if !disclose { + return nil, "", ambiguous.NonDisclosing() + } + return nil, "", ambiguous } path := found[0] lang, err := DeriveLanguage(name, filepath.Ext(path), explicitLanguage) if err != nil { + if !disclose { + var mismatch *LanguageMismatchError + if errors.As(err, &mismatch) { + return nil, "", mismatch.NonDisclosing() + } + } return nil, "", err } - f, err := openScriptFile(path) + if open == nil { + open = openScriptFile + } + f, err := open(path) if err != nil { switch { case errors.Is(err, errNonRegular): - return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonNonRegular} + return nil, "", invalid(path, ReasonNonRegular, "") case errors.Is(err, fs.ErrNotExist): // Removed between the probe and the open. - return nil, "", newNotFoundError(scriptsDir, name) + return nil, "", notFound() default: - return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonUnreadable, Detail: err.Error()} + return nil, "", invalid(path, ReasonUnreadable, err.Error()) } } defer f.Close() + // Round 8 MUST-FIX (the lookup→open race): an index hit is re-probed by + // the candidate's own Lstat, but neither that nor the open itself proves + // the file just opened is the one the index vouched for — a rename + // landing between the probe and this open can leave a DIFFERENT file + // occupying the exact name (a folded spelling of it, on a case-folding + // mount) for the descriptor's entire lifetime; a no-follow open cannot + // tell the difference, because it does not compare names, only symlink + // status. verifyUnchanged proves this AUTHORITATIVELY on f, the + // descriptor that will actually be read (round 9 MUST-FIX, the + // PROVEN-AT-OPEN rule): on every unix platform (Linux/BSD since round 8, + // darwin since round 13) by re-reading the SAME retained descriptor's + // generation once more (gen-before == index.gen == gen-after proves the + // opened entry is the one the index vouched for) plus, on darwin, an + // additional F_GETPATH basename check; on Windows by reading the opened + // descriptor's own stored spelling (GetFinalPathNameByHandle) and + // comparing it to the retained directory handle's own final path plus + // the name that was requested. Either failure closes the descriptor (via + // the defer above) and refuses rather than trusting it. Nil for the + // administrator, whose candidatesFor has nothing to recheck against. + if verifyUnchanged != nil { + if verifyErr := verifyUnchanged(f, filepath.Base(path)); verifyErr != nil { + if errors.Is(verifyErr, errIndexGenerationChanged) || errors.Is(verifyErr, errSpellingUnproven) { + return nil, "", notFound() + } + return nil, "", invalid(path, ReasonUnreadable, verifyErr.Error()) + } + } + // Re-verify on the open descriptor: this is the file that will actually be // read, whatever the path pointed at a moment ago. info, err := f.Stat() if err != nil { - return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonUnreadable, Detail: err.Error()} + return nil, "", invalid(path, ReasonUnreadable, err.Error()) } if !info.Mode().IsRegular() { - return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonNonRegular} + return nil, "", invalid(path, ReasonNonRegular, "") } // Bound the read itself rather than trusting the stat size: a file that @@ -283,13 +515,13 @@ func Resolve(scriptsDir, name, explicitLanguage string) (source []byte, language // One extra byte is requested purely to detect the overflow. data, err := io.ReadAll(io.LimitReader(f, MaxSizeBytes+1)) if err != nil { - return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonUnreadable, Detail: err.Error()} + return nil, "", invalid(path, ReasonUnreadable, err.Error()) } if len(data) > MaxSizeBytes { - return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonOversized} + return nil, "", invalid(path, ReasonOversized, "") } if len(data) == 0 { - return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonEmpty} + return nil, "", invalid(path, ReasonEmpty, "") } return data, lang, nil @@ -297,7 +529,10 @@ func Resolve(scriptsDir, name, explicitLanguage string) (source []byte, language // candidatesFor returns the paths of the script files backing `name`, in // extension order (.js then .ts), by reading the directory and comparing entry -// names BYTE FOR BYTE — the same rule List applies. +// names BYTE FOR BYTE — the same rule List applies. This is the ADMINISTRATOR +// resolver's decision, unchanged from before Spec 105 (SC-005): a directory +// the process cannot list is a refusal, whatever the constructed paths would +// have answered. // // The obvious implementation, stat-ing the two constructed paths, delegates the // name→file decision to the filesystem, and on the default macOS and Windows @@ -308,10 +543,16 @@ func Resolve(scriptsDir, name, explicitLanguage string) (source []byte, language // entries that both refused to run as ambiguous. Reading the directory removes // the filesystem's matching from the loop entirely, so the two agree on every // platform. Resolve's no-follow open remains the authoritative check. -func candidatesFor(scriptsDir, name string) ([]string, error) { - dirEntries, err := os.ReadDir(scriptsDir) +// +// The remaining three returns — the scoped opener, the post-open recheck +// (round 8 / round 9 MUST-FIX) and the per-request resource closer (round +// 11 MUST-FIX) — belong to probeCandidates alone: the administrator's +// directory-based decision has nothing to bind an open to or recheck +// against, so all three are always nil here. +func candidatesFor(scriptsDir, name string) ([]string, scopedOpener, scopedVerifier, scopedCloser, error) { + dirEntries, err := readDir(scriptsDir) if err != nil { - return nil, err + return nil, nil, nil, nil, err } present := make(map[string]bool, 2) @@ -330,7 +571,102 @@ func candidatesFor(scriptsDir, name string) ([]string, error) { found = append(found, filepath.Join(scriptsDir, name+ext)) } } - return found, nil + return found, nil, nil, nil, nil +} + +// probeCandidates is candidatesFor for the SCOPED resolver: the same two +// candidate names, each decided by the platform's constant-cost answer to +// "does the directory hold an entry spelled exactly so" (storedSpellingsOf) +// instead of by a listing. A scoped caller's refusal must cost the same +// whatever the directory holds and whatever it asks for (Spec 105 FR-012 — +// timing class is part of a non-disclosing refusal), so nothing here lists +// the directory on a request's behalf. Exactness matters because the +// filesystem's own name→file decision is case-insensitive on the default +// macOS and Windows volumes and on a Linux case-folding mount (see +// candidatesFor): a `backdoor.JS` that a probe for `backdoor.js` would open +// is not a stored script, exactly as List decides. On every unix platform +// (Linux, the BSDs, and — round 13, closing the round-10 finding-1/finding-3 +// pair — darwin too) the answer comes from a per-directory index of exact +// names that is listed once per directory change, never per request +// (storednames_other.go, codex r5 #1): an absent name and a differently +// cased one are both plain index misses, identical cost. Windows alone still +// answers from a single-entry platform call per probed path +// (storedspellings_probe_windows.go). The no-follow open remains the +// authoritative check. +// +// The verifier this returns is a post-open AUTHORITATIVE recheck (round 8 / +// round 9 MUST-FIX, the lookup→open race): storedSpellingsOf's own verify +// closure, run by resolve on the descriptor that was actually opened — on +// every unix platform a directory-generation recheck on the SAME retained +// descriptor the whole request used (storednames_other.go, round 11 +// MUST-FIX — see the opener below), with darwin adding its own F_GETPATH +// proof of the opened descriptor's stored spelling on top +// (entryname_darwin.go, round 13); on Windows a full-path comparison against +// a directory handle opened once for the request +// (storedspellings_probe_windows.go, round 11 MUST-FIX). Never nil on any +// platform: this is what makes the pre-open probe above merely a cheap gate +// rather than the authoritative decision. +// +// The opener this returns is non-nil on every unix platform (round 11 +// MUST-FIX for Linux/BSD, round 13 for darwin): it opens the winning +// candidate relative to the SAME retained directory descriptor the +// generation check and the candidate probe both used, instead of a fresh, +// independent resolution of the path — the fix for the directory-path ABA +// hole (storednames_other.go's package doc comment has the full account). +// Windows returns nil here (its own fix reaches authoritatively into the +// verifier instead, and openScriptFile in open_windows.go is already the +// reparse-hardened no-follow open), so resolve falls back to the package's +// ordinary openScriptFile. The closer releases whatever per-request +// resource the opener needs (the retained descriptor on unix, a directory +// handle on Windows) exactly once, whether or not a candidate was +// ultimately opened. +func probeCandidates(scriptsDir, name string) ([]string, scopedOpener, scopedVerifier, scopedCloser, error) { + storedExactly, open, verifyUnchanged, closeSession, err := storedSpellingsOf(scriptsDir) + if err != nil { + return nil, nil, nil, nil, err + } + found := make([]string, 0, 2) + for _, ext := range []string{extJS, extTS} { + want := name + ext + stored, err := storedExactly(want) + if err != nil { + if closeSession != nil { + closeSession() + } + return nil, nil, nil, nil, err + } + if stored { + found = append(found, filepath.Join(scriptsDir, want)) + } + } + return found, open, verifyUnchanged, closeSession, nil +} + +// listForNotFound is the directory listing newNotFoundError attaches to the +// administrator's error. A variable so the package's tests can witness that +// the scoped form never invokes it. +var listForNotFound = List + +// readDir and lstat are the package's two directory-touching primitives, +// variables so the tests can count them: a scoped resolution must never +// enumerate the directory on a request's behalf (readDir) and must probe a +// fixed number of paths (lstat) whatever the directory holds and whatever it +// asks for (Spec 105 FR-012 timing class). +var ( + readDir = os.ReadDir + lstat = os.Lstat +) + +// notFoundErrorFor builds the not-found error for one caller kind: the +// discovery-carrying administrator form (FR-004), or the scoped form that is +// constructed without touching the directory at all (Spec 105 FR-012 — the +// listing would only be thrown away, and its per-entry stat would make the +// refusal's latency grow with the number of stored scripts). +func notFoundErrorFor(scriptsDir, name string, disclose bool) *NotFoundError { + if !disclose { + return &NotFoundError{Name: name, Undisclosed: true} + } + return newNotFoundError(scriptsDir, name) } // newNotFoundError builds the discovery-carrying not-found error (FR-004). @@ -340,7 +676,7 @@ func newNotFoundError(scriptsDir, name string) *NotFoundError { if scriptsDir == "" { return err } - entries, listErr := List(scriptsDir) + entries, listErr := listForNotFound(scriptsDir) if listErr != nil { return err } @@ -363,7 +699,7 @@ func List(scriptsDir string) ([]Entry, error) { if scriptsDir == "" { return []Entry{}, nil } - dirEntries, err := os.ReadDir(scriptsDir) + dirEntries, err := readDir(scriptsDir) if err != nil { if errors.Is(err, fs.ErrNotExist) { return []Entry{}, nil diff --git a/internal/codescripts/codescripts_test.go b/internal/codescripts/codescripts_test.go index fdb8c9b14..b0ecdacb3 100644 --- a/internal/codescripts/codescripts_test.go +++ b/internal/codescripts/codescripts_test.go @@ -199,19 +199,37 @@ func TestResolve_ValidatesNameBeforeReadingTheDirectory(t *testing.T) { // not-found error all omit, because they compare extensions exactly. A name // that executes but no discovery surface reports is worse than no listing at // all, so the resolver has to agree with the listing on every platform. +// bothResolvers runs a case through the administrator and the scoped +// resolver: since codex r2 #1 they decide their candidates differently +// (directory read vs. constant-cost path probe), so a rule about which entry +// backs a name has to hold on each. +var bothResolvers = []struct { + name string + resolve func(scriptsDir, name, explicitLanguage string) ([]byte, string, error) +}{ + {"Resolve", Resolve}, + {"ResolveScoped", ResolveScoped}, +} + func TestResolve_ExtensionCaseIsExact(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "backdoor.JS", "({pwned: true})") writeScript(t, dir, "shouty.TS", "({pwned: true})") - - for _, name := range []string{"backdoor", "shouty"} { - t.Run(name, func(t *testing.T) { - src, _, err := Resolve(dir, name, "") - require.Error(t, err, "an uppercase extension is not a stored script") - var notFound *NotFoundError - require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) - assert.NotContains(t, string(src), "pwned") - }) + warmStoredNames(t, dir) // the scoped verdict must come from a built index, not from its absence + + // Both resolvers decide their candidates differently (the administrator + // reads the directory, the scoped caller probes the paths), so each is + // pinned on its own. + for _, r := range bothResolvers { + for _, name := range []string{"backdoor", "shouty"} { + t.Run(r.name+"/"+name, func(t *testing.T) { + src, _, err := r.resolve(dir, name, "") + require.Error(t, err, "an uppercase extension is not a stored script") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + assert.NotContains(t, string(src), "pwned") + }) + } } entries, err := List(dir) @@ -227,16 +245,24 @@ func TestResolve_CaseDistinctNamesAreDistinctScripts(t *testing.T) { dir := t.TempDir() writeScript(t, dir, "foo.js", "({from: 'js'})") writeScript(t, dir, "FOO.ts", "({from: 'ts'})") - - src, lang, err := Resolve(dir, "foo", "") - require.NoError(t, err, "foo.js is the only exact-cased match for \"foo\"") - assert.Equal(t, "({from: 'js'})", string(src)) - assert.Equal(t, LanguageJavaScript, lang) - - src, lang, err = Resolve(dir, "FOO", "") - require.NoError(t, err, "FOO.ts is the only exact-cased match for \"FOO\"") - assert.Equal(t, "({from: 'ts'})", string(src)) - assert.Equal(t, LanguageTypeScript, lang) + warmStoredNames(t, dir) + + for _, r := range bothResolvers { + t.Run(r.name, func(t *testing.T) { + // On Linux and the BSDs the scoped resolver settles each name + // from the directory's exact-name index (codex r5 #1), so both + // resolvers agree everywhere. + src, lang, err := r.resolve(dir, "foo", "") + require.NoError(t, err, "foo.js is the only exact-cased match for \"foo\"") + assert.Equal(t, "({from: 'js'})", string(src)) + assert.Equal(t, LanguageJavaScript, lang) + + src, lang, err = r.resolve(dir, "FOO", "") + require.NoError(t, err, "FOO.ts is the only exact-cased match for \"FOO\"") + assert.Equal(t, "({from: 'ts'})", string(src)) + assert.Equal(t, LanguageTypeScript, lang) + }) + } entries, err := List(dir) require.NoError(t, err) @@ -339,6 +365,199 @@ func TestResolve_NotFoundEmptyDirectory(t *testing.T) { assert.Contains(t, err.Error(), "no stored scripts") } +// TestNotFoundError_NonDisclosing pins the Spec 105 FR-012 agent-token form: +// the text carries only the requested name — no listing, no count, no +// directory — and is identical for an empty and a populated directory, while +// the typed identity survives errors.As (the REST surface still answers 404). +func TestNotFoundError_NonDisclosing(t *testing.T) { + populated := t.TempDir() + writeScript(t, populated, "alpha-SENTINEL.js", "1") + writeScript(t, populated, "beta.ts", "1") + + _, _, errPopulated := Resolve(populated, "missing", "") + _, _, errEmpty := Resolve(t.TempDir(), "missing", "") + + var full, none *NotFoundError + require.True(t, errors.As(errPopulated, &full)) + require.True(t, errors.As(errEmpty, &none)) + require.Equal(t, 2, full.Total, "fixture: the administrator form enumerates") + + stripped := full.NonDisclosing() + require.NotNil(t, stripped) + assert.True(t, stripped.Undisclosed) + assert.Empty(t, stripped.Available) + assert.Zero(t, stripped.Total) + assert.Empty(t, stripped.Dir, "the directory path is not disclosed either") + assert.Equal(t, "missing", stripped.Name) + + msg := stripped.Error() + assert.Contains(t, msg, `"missing"`, "the caller's own requested name is echoed") + assert.NotContains(t, msg, "SENTINEL") + assert.NotContains(t, msg, "beta") + assert.NotContains(t, msg, "Available scripts") + assert.NotContains(t, msg, populated, "the directory path is not disclosed") + assert.Contains(t, strings.ToLower(msg), "administrator") + assert.Equal(t, none.NonDisclosing().Error(), msg, + "the non-disclosing text must not depend on the directory's contents") + + // The original is untouched: the administrator keeps the enumeration. + assert.Equal(t, 2, full.Total) + assert.Contains(t, full.Error(), "alpha-SENTINEL") + + // The dispatch layer wraps the error before the REST classifier sees it, + // so the identity must survive a %w wrapper — asserting errors.As on the + // bare *NotFoundError would be vacuous. + var typed *NotFoundError + wrapped := fmt.Errorf("tool call failed: %w", stripped) + require.True(t, errors.As(wrapped, &typed), "typed identity is preserved for the REST classifier") + assert.True(t, typed.Undisclosed) +} + +// TestResolveScoped_NeverListsTheDirectory (Spec 105 FR-012, critique r1 #2): +// the scoped not-found refusal is constructed without the directory listing +// the administrator's error carries. The listing is a per-entry stat the +// scoped caller is never shown, so it must not be paid for on its behalf — +// otherwise the refusal's latency grows with the number of stored scripts +// (the spec's "timing class" is part of a non-disclosing refusal). +func TestResolveScoped_NeverListsTheDirectory(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha-SENTINEL.js", "1") + writeScript(t, dir, "beta.ts", "1") + warmStoredNames(t, dir) + + var listings int + original := listForNotFound + listForNotFound = func(scriptsDir string) ([]Entry, error) { + listings++ + return original(scriptsDir) + } + t.Cleanup(func() { listForNotFound = original }) + + _, _, err := ResolveScoped(dir, "missing", "") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + assert.True(t, notFound.Undisclosed) + assert.Zero(t, notFound.Total) + assert.Empty(t, notFound.Available) + assert.Empty(t, notFound.Dir) + assert.Equal(t, 0, listings, "the scoped refusal must not list the directory it will never disclose") + assert.NotContains(t, err.Error(), "SENTINEL") + + // Administrator control: the same miss on the same directory enumerates. + _, _, adminErr := Resolve(dir, "missing", "") + require.True(t, errors.As(adminErr, ¬Found)) + assert.Equal(t, 2, notFound.Total) + assert.Equal(t, 1, listings, "the administrator's error is built from one listing") + assert.Contains(t, adminErr.Error(), "alpha-SENTINEL") +} + +// TestResolveScoped_RefusalsCarryNoHostPath (Spec 105 FR-012, critique r1 +// #3): the sibling refusals — ambiguous, unusable, unreadable directory — +// name the caller's own script and the reason, never the scripts directory, +// a host path or a raw OS error; the administrator form keeps them. The +// typed identity survives a %w wrapper for the REST classifier in both forms. +func TestResolveScoped_RefusalsCarryNoHostPath(t *testing.T) { + t.Run("ambiguous", func(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "dup.js", "1") + writeScript(t, dir, "dup.ts", "1") + warmStoredNames(t, dir) + + _, _, err := ResolveScoped(dir, "dup", "") + var ambiguous *AmbiguousError + require.True(t, errors.As(fmt.Errorf("wrap: %w", err), &ambiguous), "want *AmbiguousError, got %T: %v", err, err) + assert.True(t, ambiguous.Undisclosed) + assert.Empty(t, ambiguous.Paths) + assert.Contains(t, err.Error(), `"dup"`) + assert.Contains(t, err.Error(), "ambiguous") + assert.NotContains(t, err.Error(), dir) + + _, _, adminErr := Resolve(dir, "dup", "") + assert.Contains(t, adminErr.Error(), dir, "the administrator keeps the paths") + }) + + for _, cell := range []struct { + name string + content string + reason string + }{ + {"empty", "", ReasonEmpty}, + {"oversized", strings.Repeat("x", MaxSizeBytes+1), ReasonOversized}, + } { + cell := cell + t.Run(cell.name, func(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "bad.js", cell.content) + warmStoredNames(t, dir) + + _, _, err := ResolveScoped(dir, "bad", "") + var invalid *InvalidError + require.True(t, errors.As(fmt.Errorf("wrap: %w", err), &invalid), "want *InvalidError, got %T: %v", err, err) + assert.True(t, invalid.Undisclosed) + assert.Equal(t, cell.reason, invalid.Reason, "the reason is the caller's recovery path and stays") + assert.Empty(t, invalid.Path) + assert.Contains(t, err.Error(), cell.reason) + assert.NotContains(t, err.Error(), dir) + + _, _, adminErr := Resolve(dir, "bad", "") + assert.Contains(t, adminErr.Error(), dir, "the administrator keeps the path") + }) + } + + t.Run("unreadable directory withholds the OS error", func(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("directory permission bits are not enforced here") + } + dir := t.TempDir() + writeScript(t, dir, "x.js", "1") + warmStoredNames(t, dir) + require.NoError(t, os.Chmod(dir, 0o000)) + t.Cleanup(func() { _ = os.Chmod(dir, 0o755) }) + + _, _, err := ResolveScoped(dir, "x", "") + var invalid *InvalidError + require.True(t, errors.As(err, &invalid), "want *InvalidError, got %T: %v", err, err) + assert.Equal(t, ReasonUnreadable, invalid.Reason) + assert.Empty(t, invalid.Detail) + assert.NotContains(t, err.Error(), dir) + assert.NotContains(t, err.Error(), "permission denied") + + _, _, adminErr := Resolve(dir, "x", "") + assert.Contains(t, adminErr.Error(), dir) + assert.Contains(t, adminErr.Error(), "permission denied", "the administrator keeps the OS error") + }) + + // This is the one refusal `resolve` returns without ever consulting + // `disclose` before the fix: DeriveLanguage's *LanguageMismatchError went + // straight out unconditionally, so a scoped caller received the same + // Extension/Derived detail an administrator does — and, because the + // error's TYPE differs from the non-disclosing NotFoundError's, a caller + // who always sends an explicit language no real script could have could + // use the type split alone as a found/not-found oracle per guessed name + // (a codex round-1 review finding on PR H0's merge with main). + t.Run("language mismatch", func(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "typed.ts", "1") + warmStoredNames(t, dir) + + _, _, err := ResolveScoped(dir, "typed", LanguageJavaScript) + var mismatch *LanguageMismatchError + require.True(t, errors.As(fmt.Errorf("wrap: %w", err), &mismatch), "want *LanguageMismatchError, got %T: %v", err, err) + assert.True(t, mismatch.Undisclosed) + assert.Empty(t, mismatch.Extension, "the scoped form withholds the real extension") + assert.Empty(t, mismatch.Derived, "the scoped form withholds the derived language") + assert.Equal(t, LanguageJavaScript, mismatch.Requested, "the caller's own input is not host information") + assert.NotContains(t, err.Error(), extTS) + assert.NotContains(t, err.Error(), LanguageTypeScript) + + _, _, adminErr := Resolve(dir, "typed", LanguageJavaScript) + var adminMismatch *LanguageMismatchError + require.True(t, errors.As(adminErr, &adminMismatch)) + assert.Equal(t, extTS, adminMismatch.Extension, "the administrator keeps the extension") + assert.Equal(t, LanguageTypeScript, adminMismatch.Derived, "the administrator keeps the derived language") + }) +} + func TestResolve_Ambiguous(t *testing.T) { dir := t.TempDir() jsPath := writeScript(t, dir, "dup.js", "1") @@ -377,6 +596,43 @@ func TestResolve_EmptyAndOversized(t *testing.T) { }) } +// TestResolve_SearchableUnreadableDirectoryIsStillUnreadableForAdmins (Spec +// 105 SC-005, codex r2 #1) is the administrator-parity control: a scripts +// directory that is searchable but not listable (0111) refused every +// administrator run before Spec 105 — the directory read that decided the +// candidates returned permission denied, and that was the verdict. The scoped +// resolver's constant-cost path probe must not leak into the administrator +// path and turn that refusal into an execution, so Resolve keeps deciding its +// candidates from the directory listing exactly as it did on origin/main. +func TestResolve_SearchableUnreadableDirectoryIsStillUnreadableForAdmins(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root: directory permissions are not enforced") + } + if runtime.GOOS == "windows" { + t.Skip("POSIX permission bits are not enforced on Windows") + } + + scriptsDir := filepath.Join(t.TempDir(), "scripts") + require.NoError(t, os.MkdirAll(scriptsDir, 0o755)) + writeScript(t, scriptsDir, "known.js", "1") + require.NoError(t, os.Chmod(scriptsDir, 0o111)) + t.Cleanup(func() { _ = os.Chmod(scriptsDir, 0o755) }) + + // Control for the control: the file itself IS reachable through the + // searchable directory, so a refusal below is the directory's doing. + direct, err := os.ReadFile(filepath.Join(scriptsDir, "known.js")) + require.NoError(t, err) + require.Equal(t, "1", string(direct)) + + src, _, err := Resolve(scriptsDir, "known", "") + require.Nil(t, src, "an administrator must not execute out of a directory it cannot list") + var invalid *InvalidError + require.True(t, errors.As(err, &invalid), "want *InvalidError, got %T: %v", err, err) + assert.Equal(t, ReasonUnreadable, invalid.Reason) + assert.Equal(t, scriptsDir, invalid.Path, "the administrator's refusal names the directory, as before") + assert.Contains(t, err.Error(), "permission denied", "the administrator keeps the OS error") +} + func TestResolve_Unreadable(t *testing.T) { if os.Geteuid() == 0 { t.Skip("running as root: permissions are not enforced") @@ -615,3 +871,112 @@ func TestResolveEmptyScriptsDirNeverTouchesCWD(t *testing.T) { t.Fatalf("empty scriptsDir must report no scripts, got %+v", nf) } } + +// countDirectoryPrimitives routes the package's two directory-touching +// primitives through counters for the duration of the test. Any index +// rebuild still in flight lands before the seams change hands. +func countDirectoryPrimitives(t *testing.T) (readDirs, lstats *int) { + t.Helper() + var rd, ls int + quiesceIndexRebuilds() + origReadDir, origLstat := readDir, lstat + readDir = func(name string) ([]os.DirEntry, error) { + rd++ + return origReadDir(name) + } + lstat = func(name string) (os.FileInfo, error) { + ls++ + return origLstat(name) + } + t.Cleanup(func() { + quiesceIndexRebuilds() + readDir, lstat = origReadDir, origLstat + }) + return &rd, &ls +} + +// TestResolveScoped_NeverReadsTheDirectory (Spec 105 FR-012, codex r1 #1): +// a scoped resolution — hit or miss — never enumerates the scripts directory. +// Skipping the not-found LISTING is not enough: an os.ReadDir on the way to +// the refusal still costs time and allocation proportional to what is stored, +// and the spec's non-disclosing refusal is indistinguishable in timing class, +// not only in body. The administrator keeps the pre-105 directory-based +// decision (SC-005, codex r2 #1): one directory read decides the candidates +// on every call, and a miss pays for the discovery listing on top. +// +// On Linux and the BSDs the scoped resolver answers from the directory's +// stored-name index (codex r5 #1), whose one listing is paid when the +// directory changes, never per request: the index is warmed first here, and +// storednames_other_test.go pins its cost rule. +func TestResolveScoped_NeverReadsTheDirectory(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha-SENTINEL.js", "1") + writeScript(t, dir, "beta.ts", "1") + warmStoredNames(t, dir) + + readDirs, _ := countDirectoryPrimitives(t) + + _, _, err := ResolveScoped(dir, "missing", "") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + assert.True(t, notFound.Undisclosed) + assert.Equal(t, 0, *readDirs, "a scoped miss must not read the directory") + + src, _, err := ResolveScoped(dir, "beta", "") + require.NoError(t, err) + assert.Equal(t, "1", string(src)) + assert.Equal(t, 0, *readDirs, "a scoped hit must not read the directory either") + + _, _, err = Resolve(dir, "beta", "") + require.NoError(t, err) + assert.Equal(t, 1, *readDirs, "the administrator's candidates are decided by one directory read, as before Spec 105") + + _, _, err = Resolve(dir, "missing", "") + require.True(t, errors.As(err, ¬Found)) + assert.Equal(t, 2, notFound.Total) + assert.Equal(t, 3, *readDirs, "the administrator's miss adds exactly one listing to its candidate read") +} + +// TestResolveScoped_MissCostIsIndependentOfDirectorySize pins the timing +// class directly: the same scoped miss against an empty directory and against +// one holding ten thousand unrelated scripts performs the same filesystem +// calls — a fixed number of path probes and no enumeration — so the refusal's +// latency and allocation cannot serve as a count oracle. +func TestResolveScoped_MissCostIsIndependentOfDirectorySize(t *testing.T) { + empty := t.TempDir() + crowded := t.TempDir() + for i := 0; i < 10_000; i++ { + f, err := os.Create(filepath.Join(crowded, fmt.Sprintf("script-%05d.js", i))) + require.NoError(t, err) + require.NoError(t, f.Close()) + } + + probe := func(dir string) (readDirs, lstats int) { + warmStoredNames(t, dir) + rd, ls := countDirectoryPrimitives(t) + _, _, err := ResolveScoped(dir, "gamma", "") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + assert.True(t, notFound.Undisclosed) + return *rd, *ls + } + + emptyReadDirs, emptyLstats := probe(empty) + crowdedReadDirs, crowdedLstats := probe(crowded) + + assert.Equal(t, 0, emptyReadDirs) + assert.Equal(t, 0, crowdedReadDirs, "ten thousand entries must not be enumerated on a scoped caller's behalf") + assert.Equal(t, emptyLstats, crowdedLstats, "the number of path probes is independent of the directory's contents") + // Round 13 (round-10 finding 3): every platform now answers a scoped + // candidate probe from a per-directory exact-spelling INDEX — Linux/BSD + // and darwin through a retained directory descriptor (fstatatEntry, + // dirfd_other.go), Windows through a retained directory handle + // (winProbeEntry, storednames_windows.go) — never through this + // package's shared lstat var, which the administrator's candidatesFor + // alone still uses. A MISS like "gamma" here never even reaches the + // per-platform probe (its name is not a key of the index), so both + // counts are 0 on every platform; storednames_other_test.go (unix) and + // storedspellings_probe_test.go (Windows) each pin their own non-zero + // HIT primitive counts on their own terms. + assert.Equal(t, 0, crowdedLstats, "the scoped candidate probe never touches the package's shared lstat var on any platform") +} diff --git a/internal/codescripts/dirfd_other.go b/internal/codescripts/dirfd_other.go new file mode 100644 index 000000000..54b6f98f5 --- /dev/null +++ b/internal/codescripts/dirfd_other.go @@ -0,0 +1,174 @@ +//go:build unix + +package codescripts + +import ( + "errors" + "os" + "time" + + "golang.org/x/sys/unix" +) + +// This file is the round 11 MUST-FIX for the Linux/BSD directory-path ABA +// hole: every earlier round's scoped resolution re-resolved scriptsDir BY +// PATH at each step of one request — once to read the directory's +// generation, once per candidate to probe it, once more after the open to +// recheck the generation — and openScriptFile's own no-follow open resolved +// the path a FOURTH time to obtain the descriptor that is actually read. +// Four independent path resolutions leave a window between each pair of +// them: retarget a replaceable symlink, an ancestor directory, or a bind +// mount to a different directory B between two of those steps and back to +// the original A before the next one, and whichever step happens to run +// while the path points at A agrees with everything the index vouches for +// while the step that runs during the B window reads or opens B's content +// instead — st_dev (round 9) rules out a substitution visible DURING one +// snapshot, never an alternation across several snapshots taken at +// different moments. +// +// The fix binds the whole scoped resolution to ONE retained directory +// descriptor per request (storedSpellingsOf, storednames_other.go): the +// scripts directory is opened by path exactly ONCE (openScopedDir); its +// generation is read from THAT descriptor (fstatDirGeneration — an fstat, +// never another Lstat of the path); a candidate is probed relative to the +// SAME descriptor (fstatatEntry, AT_SYMLINK_NOFOLLOW — the no-follow +// counterpart of Lstat, but resolved against the retained fd rather than a +// fresh join of the path); the winning candidate is OPENED relative to the +// SAME descriptor (openatEntry) — so the directory entry Fstatat already +// probed is the exact one Openat opens, never a second, independent lookup +// of the name that a retargeted symlink could have answered differently; +// and the post-open recheck reads the generation from the SAME descriptor +// once more. No path is resolved twice, so nothing about the sequence can +// observe two different directories. The request's own cost stays O(1): +// one open, two fstats (one before the lookup, one after the open), one +// fstatat, one openat. +// +// The rebuild goroutine (storednames_other.go, off the request path) opens +// its own descriptor the same way and lists through it (listScopedDir), so +// the listing and the generation the index records for it come from the +// identical open — never a second resolution of the path either. +// +// Round 13 (unify darwin onto this design; round-10 finding 1, and finding 3 +// — the case-variant timing oracle — for free): darwin now builds this build +// tag list too (`unix`, below) rather than opening a fresh path-based +// probe per request the way storedspellings_probe.go used to. x/sys/unix's +// Stat_t already spells the ctime field Mtim/Ctim uniformly across every +// platform `unix` covers — including darwin, unlike the standard library's +// syscall.Stat_t, which spells it Mtimespec/Ctimespec there — so +// defaultFstatDirGeneration below needs no darwin-specific variant. Darwin's +// own F_GETPATH stays in service as an ADDITIONAL, belt-and-suspenders proof +// on the opened descriptor (entryname_darwin.go, wired onto +// storednames_other.go's extraVerifyOpened hook) — openat's identity +// binding already proves the parent, so only the basename is worth +// re-checking. +// +// Round 13 SHOULD (finding 6 — plan9/js/wasip1 do not build): the `unix` +// build constraint (recognized by cmd/go for every real Unix GOOS; see +// https://pkg.go.dev/go/build#hdr-Build_Constraints) replaces the former +// `!darwin && !windows`, which also matched plan9, js/wasip1 and any future +// non-Unix GOOS — none of which have an x/sys/unix package to import. The +// package still builds for those targets: fallback_other.go +// (`!unix && !windows`) supplies a ResolveScoped that fails closed +// (non-disclosing not-found, matching this file's own fail-closed answer to +// an unreadable directory) and a no-op Warm, so a plan9 or js/wasm build of +// the module compiles without ever being able to serve a scoped stored +// script on those targets. +// +// Every primitive below is a variable so the package's tests can install a +// real symlink retarget between two of a request's own calls (the actual +// window the fix closes is between separate Go statements the caller makes, +// not inside a single syscall) and, for the narrower races a real retarget +// cannot land deterministically, hook the exact call the race would need to +// win. +var ( + openScopedDir = defaultOpenScopedDir + fstatDirGeneration = defaultFstatDirGeneration + fstatatEntry = defaultFstatatEntry + openatEntry = defaultOpenatEntry + listScopedDir = defaultListScopedDir +) + +// defaultOpenScopedDir opens scriptsDir once. O_DIRECTORY refuses a +// non-directory at the path outright (a symlink resolving to a plain file +// would otherwise silently "open" as if it were an empty directory); +// O_CLOEXEC keeps the descriptor from leaking into a child process spawned +// while a request holds it. +func defaultOpenScopedDir(path string) (int, error) { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + if err != nil { + return -1, &os.PathError{Op: "open", Path: path, Err: err} + } + return fd, nil +} + +// defaultFstatDirGeneration reads a directory's generation stamp from an +// already-open descriptor — fstat, never a path lookup — so it can be +// called again, after the candidate open, without re-resolving scriptsDir. +// Same tuple as dirGenerationOf (modTime, changeTime, size, ino, dev; round +// 9 MUST-FIX folded dev into it), read from golang.org/x/sys/unix.Stat_t +// directly rather than through fs.FileInfo: the field names (Dev, Ino, Mtim, +// Ctim, Size) are uniform across every platform this file builds for, unlike +// the standard syscall.Stat_t, whose ctime field is spelled differently on +// the BSDs (see dirgeneration_ctim.go / dirgeneration_ctimespec.go, which +// remain the path-based reader the package's tests and the administrator's +// bookkeeping use). +func defaultFstatDirGeneration(fd int) (dirGeneration, error) { + var st unix.Stat_t + if err := unix.Fstat(fd, &st); err != nil { + return dirGeneration{}, err + } + return dirGeneration{ + modTime: time.Unix(st.Mtim.Unix()), + changeTime: time.Unix(st.Ctim.Unix()), + size: st.Size, + ino: st.Ino, + dev: uint64(st.Dev), + }, nil +} + +// defaultFstatatEntry probes name relative to dirfd, AT_SYMLINK_NOFOLLOW — +// the candidate's own existence check, bound to the SAME descriptor the +// generation was just read from rather than a fresh Lstat of the joined +// path (which is exactly the second, independent path resolution the round +// 11 MUST-FIX removes). +func defaultFstatatEntry(dirfd int, name string) error { + var st unix.Stat_t + if err := unix.Fstatat(dirfd, name, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil { + return &os.PathError{Op: "fstatat", Path: name, Err: err} + } + return nil +} + +// defaultOpenatEntry opens name relative to dirfd, refusing a symlink +// atomically (O_NOFOLLOW — the same ELOOP/EMLINK-to-errNonRegular mapping +// open_unix.go's openScriptFile applies for the administrator) and never +// parking on a FIFO (O_NONBLOCK, for the identical reason documented +// there). This is the entry Fstatat already probed, opened relative to the +// SAME descriptor — never a second, independent lookup of the name. +func defaultOpenatEntry(dirfd int, name string) (*os.File, error) { + fd, err := unix.Openat(dirfd, name, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) + if err != nil { + if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.EMLINK) { + return nil, errNonRegular + } + return nil, &os.PathError{Op: "openat", Path: name, Err: err} + } + return os.NewFile(uintptr(fd), name), nil +} + +// defaultListScopedDir lists dirfd's entries through a DUP of the +// descriptor: os.File.Close on the dup releases only the copy, leaving the +// caller's own dirfd — and its read position — untouched. The rebuild +// goroutine calls this on the same descriptor its generation came from +// (storednames_other.go), so the listing and the generation the index +// records for it describe the identical open, never a second resolution of +// the path. +func defaultListScopedDir(dirfd int, path string) ([]string, error) { + dupFd, err := unix.Dup(dirfd) + if err != nil { + return nil, err + } + f := os.NewFile(uintptr(dupFd), path) + defer func() { _ = f.Close() }() + return f.Readdirnames(-1) +} diff --git a/internal/codescripts/dirgeneration_ctim.go b/internal/codescripts/dirgeneration_ctim.go new file mode 100644 index 000000000..380bda785 --- /dev/null +++ b/internal/codescripts/dirgeneration_ctim.go @@ -0,0 +1,25 @@ +//go:build linux || openbsd || dragonfly || solaris || aix + +package codescripts + +import ( + "io/fs" + "syscall" + "time" +) + +// dirGenerationOf reads a directory's generation stamp from its Lstat result. +// The inode, device and ctime come from the platform stat structure, whose +// ctime field is spelled Ctim here. The device is part of the stamp (round 9 +// MUST-FIX): an inode number is unique only within its device, so without it +// a bind-mount swap to another filesystem could collide on inode, size and +// both timestamps. +func dirGenerationOf(info fs.FileInfo) dirGeneration { + gen := dirGeneration{modTime: info.ModTime(), size: info.Size()} + if st, ok := info.Sys().(*syscall.Stat_t); ok { + gen.ino = uint64(st.Ino) + gen.dev = uint64(st.Dev) + gen.changeTime = time.Unix(st.Ctim.Unix()) + } + return gen +} diff --git a/internal/codescripts/dirgeneration_ctimespec.go b/internal/codescripts/dirgeneration_ctimespec.go new file mode 100644 index 000000000..d6f03ad10 --- /dev/null +++ b/internal/codescripts/dirgeneration_ctimespec.go @@ -0,0 +1,29 @@ +//go:build freebsd || netbsd || darwin + +package codescripts + +import ( + "io/fs" + "syscall" + "time" +) + +// dirGenerationOf reads a directory's generation stamp from its Lstat result. +// The inode, device and ctime come from the platform stat structure, whose +// ctime field is spelled Ctimespec here (freebsd, netbsd, and — round 13, +// darwin's join of the shared Linux/BSD design — darwin too; the standard +// library's syscall.Stat_t spells it Ctimespec on all three, unlike +// x/sys/unix.Stat_t, which dirfd_other.go's fd-based reader uses instead and +// which normalizes the field to Ctim uniformly, darwin included). The device +// is part of the stamp (round 9 MUST-FIX): an inode number is unique only +// within its device, so without it a bind-mount swap to another filesystem +// could collide on inode, size and both timestamps. +func dirGenerationOf(info fs.FileInfo) dirGeneration { + gen := dirGeneration{modTime: info.ModTime(), size: info.Size()} + if st, ok := info.Sys().(*syscall.Stat_t); ok { + gen.ino = uint64(st.Ino) + gen.dev = uint64(st.Dev) + gen.changeTime = time.Unix(st.Ctimespec.Unix()) + } + return gen +} diff --git a/internal/codescripts/entryname_darwin.go b/internal/codescripts/entryname_darwin.go new file mode 100644 index 000000000..bd884220a --- /dev/null +++ b/internal/codescripts/entryname_darwin.go @@ -0,0 +1,63 @@ +//go:build darwin + +package codescripts + +import ( + "bytes" + "os" + "path/filepath" + "syscall" + "unsafe" +) + +// Round 13 MUST-FIX (unify darwin onto the fd-bound Linux/BSD design): +// darwin now answers a scoped request from the same directory-generation +// index and the same retained-descriptor primitives every other Unix +// platform uses (dirfd_other.go, storednames_other.go — this file's build +// tag joined `unix` this round). x/sys/unix.Stat_t already normalizes the +// ctime field name across darwin and the BSDs (Mtim/Ctim, not the standard +// library syscall.Stat_t's Mtimespec/Ctimespec — see dirfd_other.go's own +// comment), so no darwin-specific generation reader is needed. +// +// What darwin keeps that Linux/BSD do not is F_GETPATH: a single-entry +// platform call that reports the exact on-disk spelling of an already-open +// descriptor. openat's own identity binding (dirfd_other.go) already proves +// the opened entry is a child of the retained directory descriptor — a +// retargeted symlink or ancestor cannot make it otherwise — so this file +// wires F_GETPATH in as an ADDITIONAL, belt-and-suspenders spelling proof +// registered on extraVerifyOpened (storednames_other.go): after the shared +// generation recheck passes, compare the opened descriptor's own reported +// basename (the parent is already bound by openat, so only the basename is +// worth comparing) to the name that was requested. +func init() { + extraVerifyOpened = func(f *os.File, want string) error { + stored, err := openedEntryName(f) + if err != nil || stored != want { + return errSpellingUnproven + } + return nil + } +} + +// openedEntryName reports the on-disk spelling of the directory entry an +// already-open descriptor was opened from (F_GETPATH), truncated to its base +// name — the proof this file registers on extraVerifyOpened, run on the +// descriptor that will actually be EXECUTED (openatEntry's result), not a +// separate pre-open probe of the same path. +func openedEntryName(f *os.File) (string, error) { + return entryNameFromFd(f.Fd()) +} + +// entryNameFromFd is the shared F_GETPATH call. +func entryNameFromFd(fd uintptr) (string, error) { + var buf [1024]byte // MAXPATHLEN + _, _, errno := syscall.Syscall(syscall.SYS_FCNTL, fd, syscall.F_GETPATH, uintptr(unsafe.Pointer(&buf[0]))) + if errno != 0 { + return "", errno + } + n := bytes.IndexByte(buf[:], 0) + if n < 0 { + n = len(buf) + } + return filepath.Base(string(buf[:n])), nil +} diff --git a/internal/codescripts/entryname_windows.go b/internal/codescripts/entryname_windows.go new file mode 100644 index 000000000..46fc95a91 --- /dev/null +++ b/internal/codescripts/entryname_windows.go @@ -0,0 +1,101 @@ +//go:build windows + +package codescripts + +import ( + "fmt" + "os" + "path/filepath" + + "golang.org/x/sys/windows" +) + +// winFileNameNormalized and winVolumeNameDOS are GetFinalPathNameByHandle's +// dwFlags bits (VOLUME_NAME_DOS | FILE_NAME_NORMALIZED, both 0 — the default +// "\\?\C:\..." form); golang.org/x/sys/windows does not export Win32 +// constants that are plain flag values rather than API surface, so they are +// named here from the documented Win32 API values. +const ( + winFileNameNormalized = 0x0 + winVolumeNameDOS = 0x0 +) + +// Round 13 MUST-FIX (round-10 findings 2 and 3 — unify Windows onto the +// index + retained-directory-handle design storednames_windows.go now +// builds): the path-based single-entry lookups this file used to hold +// (entryName/FindFirstFile, dirFinalPath, openedFinalPath, a full-path +// baseline comparison) are gone. storedExactly now answers from the same +// per-directory exact-spelling INDEX every unix platform uses (an absent +// name and a present case-variant are both plain index misses — closing +// finding 3's timing oracle for Windows too), and both the candidate probe +// and the actual open are performed RELATIVE TO ONE RETAINED DIRECTORY +// HANDLE via NtCreateFile with RootDirectory set (storednames_windows.go) — +// a rename of the directory, or a reparse point planted on an ancestor, +// cannot redirect a relative open the way it could a fresh path lookup +// (finding 2). Because the open is already structurally bound to the +// retained handle, the post-open proof needs only the opened descriptor's +// own BASENAME (winOpenedBaseName, storednames_windows.go) — the parent is +// no longer in question — so this file keeps just finalPathOfHandle, the +// shared GetFinalPathNameByHandle call that proof uses. +func openedBaseName(f *os.File) (string, error) { + full, err := finalPathOfHandle(windows.Handle(f.Fd())) + if err != nil { + return "", err + } + return filepath.Base(full), nil +} + +// getFinalPathNameByHandle is windows.GetFinalPathNameByHandle as a seam: +// entryname_windows_test.go replaces it to drive the retry logic in +// finalPathOfHandle at exact buffer-size boundaries, which no real handle +// can be made to hit deterministically (it would need a path whose +// normalized UTF-16 length is exactly 1024 units). +var getFinalPathNameByHandle = windows.GetFinalPathNameByHandle + +// finalPathNameMaxAttempts bounds finalPathOfHandle's resize-and-retry loop +// (round 15 MUST-FIX): the path GetFinalPathNameByHandle resolves a handle +// to can keep growing between calls — e.g. another process renames the +// file to a longer path while this delete-shareable handle stays open — so +// a single retry sized to one stale report can still be too small. Bounded +// rather than unbounded so a pathologically fast renamer cannot spin this +// forever; four attempts is generous headroom over the one legitimate +// undersized-then-exact-fit retry this ever needs in practice. +const finalPathNameMaxAttempts = 4 + +// finalPathOfHandle is the shared GetFinalPathNameByHandle call: the +// normalized path NTFS actually resolved a handle to, unlike the path that +// was requested, which merely echoes what was asked for. +func finalPathOfHandle(h windows.Handle) (string, error) { + flags := uint32(winFileNameNormalized | winVolumeNameDOS) + + buf := make([]uint16, 1024) + for attempt := 0; attempt < finalPathNameMaxAttempts; attempt++ { + n, err := getFinalPathNameByHandle(h, &buf[0], uint32(len(buf)), flags) + if err != nil { + return "", err + } + if int(n) < len(buf) { + // The call succeeded within this buffer — n is the resolved + // length EXCLUDING the terminator here, unlike the + // undersized-buffer case below. Only now is buf[:n] safe to + // slice. + return windows.UTF16ToString(buf[:n]), nil + } + // The path did not fit; when the buffer was too small, n is the + // required length INCLUDING the terminator, and the call does not + // error, so n == len(buf) also means truncation (an exact-length + // path leaves no room for the terminator), not only n > len(buf). + // Resize to exactly that reported size and retry — the resize + // itself is not the last word, because the path can have grown + // again by the time the retry lands (see finalPathNameMaxAttempts). + buf = make([]uint16, n) + } + // Exhausted the bound without a call ever reporting a length that fit + // the buffer it was given: the path is growing faster than we can size + // for it (or something is persistently wrong). Return a plain error + // rather than slicing a stale/undersized buffer — the caller + // (winOpenedBaseName's verifyUnchanged) already treats any non-nil + // error here the same as a spelling mismatch, folding it into + // errSpellingUnproven, a non-disclosing refusal (SC-005). + return "", fmt.Errorf("codescripts: GetFinalPathNameByHandle did not settle within %d attempts", finalPathNameMaxAttempts) +} diff --git a/internal/codescripts/entryname_windows_test.go b/internal/codescripts/entryname_windows_test.go new file mode 100644 index 000000000..b061fe315 --- /dev/null +++ b/internal/codescripts/entryname_windows_test.go @@ -0,0 +1,187 @@ +//go:build windows + +package codescripts + +import ( + "testing" + "unsafe" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// writeUTF16Content fills filePath (a buffer of filePathSize UTF-16 units, as +// GetFinalPathNameByHandle receives it) with n placeholder characters, +// simulating what a real call writes into the caller's buffer on success. +func writeUTF16Content(filePath *uint16, filePathSize, n uint32) { + if n == 0 { + return + } + out := unsafe.Slice(filePath, filePathSize) + for i := uint32(0); i < n && i < filePathSize; i++ { + out[i] = 'a' + uint16(i%26) + } +} + +// TestFinalPathOfHandle_GrowingPathDoesNotPanic pins the round-15 MUST-FIX: +// a single resize-and-retry is not enough when the path GetFinalPathNameByHandle +// resolves keeps growing between calls (e.g. another process extends the +// path of a file while this delete-shareable handle stays open). Before the +// fix, a second oversized report after the one retry fell straight into +// `buf[:n]` with a buffer still sized to the FIRST report, which panics with +// a slice-bounds-out-of-range whenever the second n exceeds that stale +// length. The fix loops the resize, bounded by finalPathNameMaxAttempts, and +// only slices once a call's n actually fits the buffer it was given. +func TestFinalPathOfHandle_GrowingPathDoesNotPanic(t *testing.T) { + const initialBufLen = 1024 + + t.Run("exact-fit — first call already fits, no retry", func(t *testing.T) { + calls := 0 + orig := getFinalPathNameByHandle + getFinalPathNameByHandle = func(_ windows.Handle, filePath *uint16, filePathSize uint32, _ uint32) (uint32, error) { + calls++ + const n = initialBufLen - 1 + writeUTF16Content(filePath, filePathSize, n) + return n, nil + } + t.Cleanup(func() { getFinalPathNameByHandle = orig }) + + got, err := finalPathOfHandle(windows.Handle(0)) + require.NoError(t, err) + assert.Equal(t, 1, calls) + assert.NotEmpty(t, got) + }) + + t.Run("one retry — second call's report fits the resized buffer", func(t *testing.T) { + calls := 0 + orig := getFinalPathNameByHandle + getFinalPathNameByHandle = func(_ windows.Handle, filePath *uint16, filePathSize uint32, _ uint32) (uint32, error) { + calls++ + switch calls { + case 1: + // Too small: reports the required size (including the + // terminator), writes nothing usable. + return initialBufLen + 500, nil + case 2: + require.Equal(t, uint32(initialBufLen+500), filePathSize, + "retry must size the buffer to the first report") + n := filePathSize - 1 + writeUTF16Content(filePath, filePathSize, n) + return n, nil + default: + t.Fatalf("unexpected call %d", calls) + return 0, nil + } + } + t.Cleanup(func() { getFinalPathNameByHandle = orig }) + + got, err := finalPathOfHandle(windows.Handle(0)) + require.NoError(t, err) + assert.Equal(t, 2, calls) + assert.NotEmpty(t, got) + }) + + t.Run("forced second growth — path keeps growing past the first retry, no panic, clean error", func(t *testing.T) { + calls := 0 + orig := getFinalPathNameByHandle + getFinalPathNameByHandle = func(_ windows.Handle, filePath *uint16, filePathSize uint32, _ uint32) (uint32, error) { + calls++ + // Every call reports a size larger than the buffer it was just + // given — the pathological "path keeps growing forever" case. + // Before the fix this second growth (on what used to be the + // unconditional final slice) panicked; now it must instead loop + // up to the bound and then return an error. + return filePathSize + 100, nil + } + t.Cleanup(func() { getFinalPathNameByHandle = orig }) + + require.NotPanics(t, func() { + got, err := finalPathOfHandle(windows.Handle(0)) + assert.Error(t, err, "must fail closed, not return an unproven/truncated path") + assert.Empty(t, got) + }) + assert.Equal(t, finalPathNameMaxAttempts, calls, "must stop retrying at the bound, not loop forever") + }) + + t.Run("growth settles within the bound — succeeds on a later attempt", func(t *testing.T) { + calls := 0 + orig := getFinalPathNameByHandle + getFinalPathNameByHandle = func(_ windows.Handle, filePath *uint16, filePathSize uint32, _ uint32) (uint32, error) { + calls++ + if calls < finalPathNameMaxAttempts { + // Keeps growing by more than the last resize, forcing + // another loop iteration, right up to (but not exceeding) + // the bound. + return filePathSize + 10, nil + } + // Settles on the last permitted attempt. + n := filePathSize - 1 + writeUTF16Content(filePath, filePathSize, n) + return n, nil + } + t.Cleanup(func() { getFinalPathNameByHandle = orig }) + + got, err := finalPathOfHandle(windows.Handle(0)) + require.NoError(t, err) + assert.Equal(t, finalPathNameMaxAttempts, calls) + assert.NotEmpty(t, got) + }) +} + +// TestFinalPathOfHandle_RetriesAtBufferSizeBoundary pins the round-14 fix: +// GetFinalPathNameByHandle's returned size (n) INCLUDES the null terminator +// when the initial 1024-unit buffer was too small, so a path whose resolved +// length makes the first call report n == len(buf) (not just n > len(buf)) +// must also retry — a buffer that fits exactly leaves no room for that +// terminator. Before the fix, that boundary case fell through the `n > +// len(buf)` check and returned a truncated/unspecified result instead of +// retrying at the reported size. +func TestFinalPathOfHandle_RetriesAtBufferSizeBoundary(t *testing.T) { + const initialBufLen = 1024 // mirrors finalPathOfHandle's fixed initial buffer size + + cases := []struct { + name string + firstN uint32 // what the first GetFinalPathNameByHandle call reports + expectCalls int + }{ + {"one under the initial buffer size — succeeds on the first call", initialBufLen - 1, 1}, + {"exactly the initial buffer size — must retry (the fixed off-by-one)", initialBufLen, 2}, + {"one over the initial buffer size — already retried before the fix", initialBufLen + 1, 2}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + orig := getFinalPathNameByHandle + getFinalPathNameByHandle = func(_ windows.Handle, filePath *uint16, filePathSize uint32, _ uint32) (uint32, error) { + calls++ + if calls == 1 { + if tc.firstN < initialBufLen { + // Succeeds on the first try: the buffer already held + // the whole string, so the real call would have + // written it and returned the string's length + // (excluding the terminator). + writeUTF16Content(filePath, filePathSize, tc.firstN) + return tc.firstN, nil + } + // Too small: Win32 reports the required size, including + // the terminator, and writes nothing usable. + return tc.firstN, nil + } + // Retry: finalPathOfHandle must size the new buffer to + // exactly what the first call reported. + require.Equal(t, tc.firstN, filePathSize, "retry must size the buffer to the reported n") + content := tc.firstN - 1 // the retry buffer has room for the terminator too + writeUTF16Content(filePath, filePathSize, content) + return content, nil + } + t.Cleanup(func() { getFinalPathNameByHandle = orig }) + + got, err := finalPathOfHandle(windows.Handle(0)) + require.NoError(t, err) + assert.Equal(t, tc.expectCalls, calls, "unexpected number of GetFinalPathNameByHandle calls") + assert.NotEmpty(t, got, "must have read back the resolved path") + }) + } +} diff --git a/internal/codescripts/fallback_other.go b/internal/codescripts/fallback_other.go new file mode 100644 index 000000000..7bc21f992 --- /dev/null +++ b/internal/codescripts/fallback_other.go @@ -0,0 +1,71 @@ +//go:build !unix && !windows + +package codescripts + +import ( + "errors" + "io/fs" + "os" +) + +// Round 13 SHOULD (finding 6): a build target that is neither `unix` +// (dirfd_other.go, storednames_other.go — the fd-bound Linux/BSD/darwin +// design) nor `windows` (open_windows.go, storedspellings_probe_windows.go) +// — plan9, js/wasm, wasip1, or any future GOOS this package has not been +// taught a directory-descriptor primitive for — has no platform primitive +// this package can trust to answer a scoped request, or even to open the +// administrator's own no-follow read (open_unix.go needs +// syscall.O_NOFOLLOW/ELOOP/EMLINK, none of which exist on plan9 or +// js/wasm). The concrete failure this closes: `GOOS=plan9 go build +// ./internal/codescripts` and `GOOS=js GOARCH=wasm go build +// ./internal/codescripts` failed outright before this file existed, because +// every one of storedSpellingsOf/Warm/SetIndexClockForTest/openScriptFile +// was defined only under tags that (before round 13) or now (after +// narrowing dirfd_other.go, storednames_other.go and open_unix.go to their +// correct, narrower tags) exclude these targets entirely. +// +// Rather than leave the package unable to compile there, it compiles and +// fails CLOSED and undisclosed: Warm is a no-op (there is no index to +// build, so nothing needs warming); storedSpellingsOf and openScriptFile +// both report the stored script as not found — wrapped so +// errors.Is(err, fs.ErrNotExist) is true — which resolve (codescripts.go) +// turns into the caller's ordinary NotFoundError, non-disclosing for a +// scoped caller exactly as SC-005 requires and, for an administrator, +// the same not-found form a genuinely empty or unreadable directory +// produces on every other platform. Neither function silently succeeds, +// silently discloses anything about what scriptsDir might hold, or panics; +// the package is simply unable to serve a stored script on a target with +// no directory-descriptor primitive of its own. +var errUnsupportedPlatform = errors.New("codescripts: stored scripts are not supported on this platform") + +// Warm is a no-op: there is no index to build on a platform with no +// directory-descriptor primitive. +func Warm(string) error { return nil } + +// SetIndexClockForTest itself is shared, no-build-tag code (indexclock.go): +// there is no settle window to fake on a platform with no index at all, but +// production never calls it here either way, so the shared (real) clock +// override is harmless to inherit rather than needing its own no-op. + +// storedSpellingsOf always reports the requested name as not found — +// fail-closed, never fail-open — since this platform has no primitive this +// package trusts to answer whether scriptsDir holds an entry at all. +func storedSpellingsOf(string) (storedExactly func(want string) (bool, error), open func(path string) (*os.File, error), verifyUnchanged func(f *os.File, want string) error, closeSession func(), err error) { + return nil, nil, nil, nil, errUnsupportedPlatformNotFound() +} + +// openScriptFile always fails: there is no platform no-follow primitive +// here for the administrator's own read to rely on, so the safe answer is +// "not found" rather than an open that cannot promise it refused a symlink. +func openScriptFile(string) (*os.File, error) { + return nil, errUnsupportedPlatformNotFound() +} + +// errUnsupportedPlatformNotFound wraps errUnsupportedPlatform so +// errors.Is(err, fs.ErrNotExist) is true — resolve (codescripts.go) treats +// any fs.ErrNotExist from a candidates()/open call as an ordinary not-found, +// never as ReasonUnreadable, which is what keeps this fail-closed answer +// non-disclosing for a scoped caller. +func errUnsupportedPlatformNotFound() error { + return &fs.PathError{Op: "open", Path: "", Err: errors.Join(errUnsupportedPlatform, fs.ErrNotExist)} +} diff --git a/internal/codescripts/fallback_other_test.go b/internal/codescripts/fallback_other_test.go new file mode 100644 index 000000000..66c054767 --- /dev/null +++ b/internal/codescripts/fallback_other_test.go @@ -0,0 +1,32 @@ +//go:build !unix && !windows + +package codescripts + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// Round 14 SHOULD (codex r11 #2): the shared tests in codescripts_test.go +// call warmStoredNames and quiesceIndexRebuilds unconditionally +// (TestResolveScoped_NeverReadsTheDirectory and friends), but those helpers +// previously existed only in the unix-tagged (storednames_other_test.go) and +// windows-tagged (storedspellings_probe_test.go) test files. A GOOS with +// neither tag — plan9, js/wasm, wasip1 — failed to even COMPILE its test +// binary, so fallback_other.go's fail-closed behavior (Warm as a no-op, +// storedSpellingsOf/openScriptFile always "not found") was never exercised +// there. These mirror the unix/windows helpers' names and signatures with +// the trivial bodies this platform's no-index design actually needs. + +// quiesceIndexRebuilds is a no-op here: there is no index, and therefore no +// rebuild goroutine, to wait on. +func quiesceIndexRebuilds() {} + +// warmStoredNames calls the package's real Warm, which fallback_other.go +// defines as a no-op returning nil — there is nothing to build an index +// from on a platform with no directory-descriptor primitive of its own. +func warmStoredNames(t *testing.T, dir string) { + t.Helper() + require.NoError(t, Warm(dir)) +} diff --git a/internal/codescripts/indexclock.go b/internal/codescripts/indexclock.go new file mode 100644 index 000000000..bfcd62298 --- /dev/null +++ b/internal/codescripts/indexclock.go @@ -0,0 +1,69 @@ +package codescripts + +import "time" + +// generationSettleTime is how far a directory's stamp must predate a listing +// for its index to be trusted until the stamp moves. Timestamps can be +// coarse (vfat: two seconds; round 13: also a FAT-formatted volume on +// Windows, whose directory write time carries the same two-second +// resolution — NTFS itself is fine-grained, but a scripts directory is not +// guaranteed to live on it), so a write landing in the same tick as the +// recorded stamp would leave it unchanged; until the stamp is older than +// the coarsest tick, requests keep scheduling a refresh — at most one per +// window, and off the request path. The bound depends on the clock alone, +// never on the requested name. +// +// No build tag: shared by both platform index implementations — unix +// (storednames_other.go) and Windows (storedspellings_probe_windows.go) — +// which never build together, so one definition and one clock serve +// whichever is active rather than each keeping its own copy that could +// drift out of step. +const generationSettleTime = 2 * time.Second + +// maxRebuildAttempts bounds how many times one rebuild re-lists when the +// directory's generation keeps moving out from under it (round 8 SHOULD): a +// directory that never stops changing must not keep this goroutine listing +// forever, nor block Warm forever. After the bound, whatever the last +// attempt installed stays as the index — the next request finds it stale +// against the directory's CURRENT generation and refuses fail-closed, rather +// than this loop trusting an unconfirmed listing or spinning on one that can +// never confirm. +const maxRebuildAttempts = 3 + +// rebuildBackoff is the minimum gap between the end of one ASYNC rebuild +// goroutine and the start of the next for the same directory (round 8 +// SHOULD). Without it, a directory changing on every request would let +// scheduling spawn a fresh rebuild the instant the bounded one above gives +// up. During the backoff a request's own cost is unchanged — answered +// fail-closed from whatever the index holds (or does not); only the new +// rebuild goroutine is withheld. +const rebuildBackoff = time.Second + +// maxStoredNameIndexes bounds a platform's index map for bare (never-Warmed) +// use — the server calls Warm whenever the active scripts directory changes, +// and Warm keeps only that one directory's index (pruneOtherIndexesLocked), +// so this cap matters only for the directories a scoped request alone +// touches without ever being Warmed for. Shared by both platform index +// implementations for the same reason as everything else in this file. +const maxStoredNameIndexes = 4 + +// indexClock is time.Now, a variable so the tests can settle an index +// without waiting. +var indexClock = time.Now + +// SetIndexClockForTest overrides the clock the settle check reads (round 9 +// MUST-FIX) and returns a func that restores it. A directory's on-disk +// change stamp cannot be forged from user space — it is exactly what makes +// the settle window a real guarantee — so a caller outside this package +// that needs a freshly written scripts directory treated as settled at once +// (an internal/server fixture, say) has no way to fake it by backdating a +// file; it must move the clock the settle check reads instead, as this +// package's own tests do internally. Test-only: production code never +// calls this, and callers outside this package must restore it (defer the +// returned func, or t.Cleanup) before any other test observes the +// override. +func SetIndexClockForTest(now func() time.Time) (restore func()) { + prev := indexClock + indexClock = now + return func() { indexClock = prev } +} diff --git a/internal/codescripts/open_fifo_unix_test.go b/internal/codescripts/open_fifo_unix_test.go index 17a917aa6..dca75ed25 100644 --- a/internal/codescripts/open_fifo_unix_test.go +++ b/internal/codescripts/open_fifo_unix_test.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build unix package codescripts diff --git a/internal/codescripts/open_unix.go b/internal/codescripts/open_unix.go index 335510f92..e68a0d94c 100644 --- a/internal/codescripts/open_unix.go +++ b/internal/codescripts/open_unix.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build unix package codescripts @@ -8,6 +8,12 @@ import ( "syscall" ) +// Round 13 SHOULD (finding 6): this file's build tag narrowed from +// `!windows` to `unix` — the constants it needs (syscall.O_NOFOLLOW, +// ELOOP, EMLINK) do not exist on plan9 or js/wasm, so `!windows` alone +// still failed to build there; fallback_other.go supplies a stub for +// every non-unix, non-Windows target instead. +// // openScriptFile opens a stored script for reading, rejecting a symlink at the // final path component ATOMICALLY: O_NOFOLLOW makes the kernel refuse the open // (ELOOP) instead of resolving the link, so there is no check-then-open window diff --git a/internal/codescripts/open_windows.go b/internal/codescripts/open_windows.go index deab8b2b1..d6d2fc182 100644 --- a/internal/codescripts/open_windows.go +++ b/internal/codescripts/open_windows.go @@ -2,21 +2,78 @@ package codescripts -import "os" +import ( + "errors" + "os" -// openScriptFile opens a stored script for reading. Windows has no O_NOFOLLOW, -// so the symlink/reparse-point rejection is BEST-EFFORT: the path is Lstat'ed -// first and the descriptor re-verified by the caller after the open. The -// residual window is narrow and creating a symlink on Windows requires -// elevation (or developer mode) in the first place; the confinement boundary -// itself is the name validator, which does not depend on this check. + "golang.org/x/sys/windows" +) + +// openScriptFile opens a stored script for reading without ever following a +// reparse point at the final path component (round 11 MUST-FIX). Earlier +// rounds Lstat'ed the path to rule out a symlink/junction and then called +// os.Open, which DOES follow a reparse point: a symlink or junction planted +// between the Lstat and the Open — or a symlinked ANCESTOR directory +// retargeted the same way — is followed straight through to whatever it now +// points at, and the caller's own descriptor-spelling proof used to compare +// only a basename (round 9), which an identically named file reached +// through the reparse point satisfies just as well. +// +// FILE_FLAG_OPEN_REPARSE_POINT makes CreateFile open the reparse point +// ITSELF rather than transparently resolving it — the Windows equivalent of +// O_NOFOLLOW — so there is no check-then-open window: whatever the entry +// is, this is the handle it opens, atomically. GetFileInformationByHandle on +// that handle then refuses a reparse point or a directory outright, exactly +// as O_NOFOLLOW plus the regular-file Fstat check does on Unix. +// +// Round 13 MUST-FIX (round-10 finding 4): the share mode widened from +// FILE_SHARE_READ alone to FILE_SHARE_READ|WRITE|DELETE — the same sharing +// os.Open itself requests (syscall.Open on windows: FILE_SHARE_READ| +// FILE_SHARE_WRITE) plus DELETE, so this read cannot itself block a +// concurrent atomic replace (rename-over) of the very file it is reading. +// Concrete failure this closes: an editor (or an atomic-write deploy of a +// new script version) holds the file open with delete sharing enabled — +// origin/main's os.Open could still read it; the round-11 CreateFile with +// FILE_SHARE_READ alone returned a sharing violation instead, a behavior +// change from the pre-Spec-105 administrator path that SC-005 does not +// call for, and this open in turn withheld FILE_SHARE_DELETE from ITS OWN +// handle, which would have blocked that same atomic replace for as long as +// this read holds the file open. func openScriptFile(path string) (*os.File, error) { - info, err := os.Lstat(path) + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + h, err := windows.CreateFile(p, + windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_OPEN_REPARSE_POINT, + 0) if err != nil { + // Without FILE_FLAG_BACKUP_SEMANTICS (deliberately not requested: it + // would let a process holding SeBackupPrivilege read past ACLs, which + // os.Open never did) CreateFile refuses a DIRECTORY with + // ERROR_ACCESS_DENIED before any attribute is visible. The + // administrator's pre-105 answer for a directory candidate is + // non-regular, not unreadable (SC-005), so classify that one case + // from the attributes. + if errors.Is(err, windows.ERROR_ACCESS_DENIED) { + if attrs, aerr := windows.GetFileAttributes(p); aerr == nil && attrs&windows.FILE_ATTRIBUTE_DIRECTORY != 0 { + return nil, errNonRegular + } + } + return nil, err + } + var fi windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(h, &fi); err != nil { + _ = windows.CloseHandle(h) return nil, err } - if !info.Mode().IsRegular() { + if fi.FileAttributes&(windows.FILE_ATTRIBUTE_REPARSE_POINT|windows.FILE_ATTRIBUTE_DIRECTORY) != 0 { + _ = windows.CloseHandle(h) return nil, errNonRegular } - return os.Open(path) + return os.NewFile(uintptr(h), path), nil } diff --git a/internal/codescripts/rebuildsemaphore.go b/internal/codescripts/rebuildsemaphore.go new file mode 100644 index 000000000..261cd3bf5 --- /dev/null +++ b/internal/codescripts/rebuildsemaphore.go @@ -0,0 +1,41 @@ +package codescripts + +// spawnIndexRebuild runs one ASYNC index rebuild on its own goroutine, +// shared by both platform index implementations (storednames_other.go, +// storednames_windows.go — they never build together). A variable so the +// tests can hold a rebuild back and prove what a request does on its own +// goroutine, then land it deliberately. +var spawnIndexRebuild = func(rebuild func()) { go rebuild() } + +// maxConcurrentRebuilds bounds how many ASYNC index-rebuild goroutines may +// run at once, PROCESS-WIDE across every directory's index and both +// platform index implementations (round 13 SHOULD, finding 5) — the unix +// one (storednames_other.go) and the Windows one (storedspellings_probe_windows.go), +// both of which share this single semaphore rather than each keeping its +// own bound. Cancellation (round 11 SHOULD, idx.ctx) stops an evicted +// index's rebuild only at its next checkpoint — between listing attempts, +// or right before installing — never mid-listing, which is uninterruptible; +// a directory backed by a slow or stalled filesystem can therefore leave a +// rebuild goroutine (and its retained directory handle/descriptor) running +// for as long as that one blocking listing takes, however many DIFFERENT +// directories keep triggering new ones in the meantime. rebuildSlots below +// is what keeps that count bounded rather than merely eventually-cancelled. +// +// No build tag: this file compiles identically on every platform so the +// unix and Windows index implementations — which never build together — +// can each import the identical semaphore and bound without duplicating its +// definition (or its capacity) in two places that could drift apart. +const maxConcurrentRebuilds = 2 + +// rebuildSlots is the process-wide semaphore a scheduleRebuildLocked +// implementation acquires before spawning an ASYNC rebuild and releases +// when that rebuild returns — win, lose, or cancelled. A directory whose +// rebuild cannot acquire a slot is not queued or blocked on one becoming +// free: scheduling simply does not spawn it this time, leaving the index +// exactly as stale as it already was. Nothing is lost — the NEXT request +// against that directory still finds it stale and tries to schedule again, +// acquiring a slot afresh. A capacity of 2 lets one directory's rebuild +// proceed while another directory's request-triggered rebuild is scheduled +// too, without letting an unbounded number of evicted, still-listing +// goroutines accumulate. +var rebuildSlots = make(chan struct{}, maxConcurrentRebuilds) diff --git a/internal/codescripts/storednames_other.go b/internal/codescripts/storednames_other.go new file mode 100644 index 000000000..f4a61a0c9 --- /dev/null +++ b/internal/codescripts/storednames_other.go @@ -0,0 +1,657 @@ +//go:build unix + +package codescripts + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "sync" + "time" + + "golang.org/x/sys/unix" +) + +// Linux and the BSDs resolve names case-sensitively on their native +// filesystems, but a case-folding mount (vfat, an ext4 casefold directory, a +// bind mount from a case-insensitive host) finds `backdoor.JS` for +// `backdoor.js` just as the default APFS volume does — and unlike APFS +// (F_GETPATH) they offer no single-entry call that reports how an entry is +// spelled on disk: a readlink of /proc/self/fd/N echoes the spelling that +// was looked up, not the one stored. The only exact answer is the directory +// listing, and a listing paid on a scoped caller's request is what Spec 105 +// FR-012 forbids: its cost grows with the directory, and paying it only when +// a probe hits (codex r5 #1) made the presence of a differently cased entry +// cost O(directory) while absence cost O(1) — a timing oracle on the stored +// names. +// +// Round 13 (round-10 finding 1 and finding 3): darwin answers from this same +// index rather than a per-request F_GETPATH probe. F_GETPATH is real and +// exact, so darwin's OWN probe never had a case-folding blind spot — but it +// answered a hit (Lstat succeeds, then entryName) and a miss (Lstat alone) +// with a DIFFERENT number of platform calls, which is exactly the timing +// oracle finding 3 named: a scoped caller could distinguish "no entry" from +// "a case-variant exists" by latency alone, whatever a non-disclosing +// refusal's contract (SC-005) requires. Answering darwin from the index too +// makes an absent name and a present case-variant both plain index MISSES — +// identical work, an O(1) map lookup, neither one reaching Fstatat — closing +// the oracle the same way it is already closed for Linux/BSD. See +// dirfd_other.go's own round-13 note for why no darwin-specific generation +// reader was needed to do this, and entryname_darwin.go for the +// belt-and-suspenders F_GETPATH proof darwin keeps on top of this index. +// +// So the scoped resolver answers from a stored-name INDEX instead: the exact +// spellings a scripts directory holds, maintained OFF the request path. The +// index is built when the server learns its scripts directory (Warm) and +// rebuilt by a single-flight goroutine whenever a request finds it behind the +// directory's GENERATION. No request ever lists: it answers from the index +// that exists — an exact hit is re-probed by the candidate's own no-follow +// stat and opened no-follow, so a removed or replaced file fails closed; a +// script added since the listing is refused until the rebuild lands, +// milliseconds later (the administrator's directory read sees it at once). +// Every request — hit, miss or case-variant, cold or warm, in a directory of +// ten thousand entries or none — costs the same bounded number of directory +// primitives and an O(1) set lookup. Listing cost follows the +// administrator's writes, never the requested name, and never lands on a +// caller's goroutine. +// +// The index answers ONLY for the generation it was built against (round 8 +// MUST-FIX): a stale index is refused exactly as a never-built one is, fail +// closed, until its own rebuild lands (a call landing within milliseconds of +// a directory change is refused once — retry). The generation is checked +// once more after an exact-set hit's no-follow open (round 8 MUST-FIX, the +// lookup→open race): gen-before == index.gen == gen-after is what proves the +// file the open just read is the one the index vouched for. +// +// A matching generation is not enough on its own (round 9 MUST-FIX): a +// coarse filesystem timestamp (vfat: two seconds) can leave a directory's +// stamp UNCHANGED across a rename that lands in the same tick as the stamp +// the index was listed against. An index may therefore AUTHORIZE a hit only +// once it is SETTLED: its stamp predates the listing by at least +// generationSettleTime, so no write still landing on that stamp could have +// escaped it. +// +// Round 11 MUST-FIX (the directory-path ABA hole): every check above — +// gen-before, the candidate probe, gen-after — and the eventual open used to +// be FOUR INDEPENDENT resolutions of scriptsDir BY PATH. A replaceable +// symlink, ancestor directory, or bind mount retargeted between two of those +// steps and back before the next one let each step separately agree with a +// DIFFERENT directory than the one the others saw — st_dev (round 9) rules +// out a substitution visible during one snapshot, never an alternation +// across several. The fix (dirfd_other.go) binds the entire request to ONE +// retained directory descriptor: opened by path exactly once, then every +// generation read, the candidate probe, and the open itself are all +// performed RELATIVE TO THAT DESCRIPTOR (fstat / fstatat / openat) — no +// second path resolution exists for anything to retarget. See +// dirfd_other.go for the full account and storedSpellingsOf below for where +// the descriptor is opened and released. +// +// Round 11 SHOULD (cancellable rebuilds): a directory that keeps changing +// must not leave orphaned rebuild goroutines running forever after their +// index has been evicted (LRU) or pruned (Warm keeping only the active +// directory) — each index owns a context that eviction cancels, and its +// rebuild goroutine (the ASYNC, request-scheduled kind only — Warm's own +// synchronous rebuild is what the caller is waiting on and always runs to +// completion) checks it between listing attempts and once more before +// installing a result, so a cancelled rebuild stops promptly and writes +// nothing nobody will read. See storedNames.rebuild below. + +// storedNames is the exact-spelling index of one scripts directory. names is +// replaced, never mutated, so a set handed out under the lock stays valid +// after it is released. +type storedNames struct { + mu sync.Mutex + names map[string]struct{} // nil until a build has landed, or when it failed + err error // the last build's failure; nil when names is valid + gen dirGeneration // the directory's stamp when names was listed + settled bool // gen predates the listing by more than any timestamp tick + + // building is the single-flight flag: at most one rebuild goroutine per + // directory. landed is closed when that rebuild has finished, so Warm + // and the tests can wait for it without polling. + building bool + landed chan struct{} + + // refreshAfter bounds how often an UNSETTLED index schedules a refresh: + // at most once per generationSettleTime, whatever the request rate. + refreshAfter time.Time + + // nextAttempt bounds how soon a NEW rebuild goroutine may start after + // the previous one finished (round 8 SHOULD): a continuously changing + // directory would otherwise let scheduleRebuildLocked spawn another + // rebuild the instant the last one gives up, listing back to back + // forever. Set at the end of every ASYNC rebuild, win or lose; zero + // means none has ever finished. + nextAttempt time.Time + + // ctx/cancel bind this index's ASYNC rebuild goroutines to the index's + // own lifetime (round 11 SHOULD): every place that discards this index + // — LRU eviction, Warm pruning every OTHER directory, forgetIndex — + // cancels ctx before the map forgets it, so a rebuild goroutine still + // mid-listing for a directory nobody will query through THIS index any + // longer stops re-listing and installs nothing rather than racing the + // eviction to finish a write no reader needed. wg is the seam a test (or + // a future caller) waits on to know the goroutine has actually + // returned, not merely that cancel was called; every rebuild call — the + // async ones AND Warm's own synchronous one — is wg.Add(1)'d before it + // starts, so wg.Wait() always reflects work truly in flight. + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup +} + +// storedIndexes holds one *storedNames per cleaned scripts directory, +// bounded so it tracks the directories actually in use rather than every +// directory ever used (round 9 SHOULD): the server calls Warm whenever the +// active scripts directory changes, and Warm keeps only the directory it was +// just called for (pruneOtherIndexesLocked) — so in normal operation exactly +// one index is warm. storedNamesIndex additionally caps the map itself at +// maxStoredNameIndexes, evicting the least-recently-used entry, for the bare +// (never-Warmed) case a scoped request alone can produce. +var ( + storedIndexesMu sync.Mutex + storedIndexes = map[string]*storedNames{} + storedIndexesLRU []string // least-recently-used first; a touched key moves to the end +) + +// storedNamesIndex returns the index of one cleaned scripts directory, +// creating an empty (never built) one — with its own cancellation context — +// on first use, and records the access for LRU eviction. +func storedNamesIndex(key string) *storedNames { + storedIndexesMu.Lock() + defer storedIndexesMu.Unlock() + idx, ok := storedIndexes[key] + if !ok { + ctx, cancel := context.WithCancel(context.Background()) + idx = &storedNames{ctx: ctx, cancel: cancel} + storedIndexes[key] = idx + } + touchIndexLocked(key) + evictExcessLocked() + return idx +} + +// touchIndexLocked moves key to the most-recently-used end of the LRU order. +// storedIndexesMu must be held. +func touchIndexLocked(key string) { + for i, k := range storedIndexesLRU { + if k == key { + storedIndexesLRU = append(storedIndexesLRU[:i], storedIndexesLRU[i+1:]...) + break + } + } + storedIndexesLRU = append(storedIndexesLRU, key) +} + +// evictExcessLocked drops the least-recently-used indexes once the map holds +// more than maxStoredNameIndexes, cancelling each one's rebuild context +// first (round 11 SHOULD) so an in-flight async rebuild for a directory this +// map no longer tracks does not keep listing. storedIndexesMu must be held. +func evictExcessLocked() { + for len(storedIndexesLRU) > maxStoredNameIndexes { + oldest := storedIndexesLRU[0] + storedIndexesLRU = storedIndexesLRU[1:] + if idx, ok := storedIndexes[oldest]; ok { + idx.cancel() + } + delete(storedIndexes, oldest) + } +} + +// pruneOtherIndexesLocked drops every index but keep — Warm's own promise +// that only the active scripts directory stays warm — cancelling each +// dropped index's rebuild context first (round 11 SHOULD). storedIndexesMu +// must be held. +func pruneOtherIndexesLocked(keep string) { + for k, idx := range storedIndexes { + if k != keep { + idx.cancel() + delete(storedIndexes, k) + } + } + kept := storedIndexesLRU[:0] + for _, k := range storedIndexesLRU { + if k == keep { + kept = append(kept, k) + } + } + storedIndexesLRU = kept +} + +// forgetIndex removes one directory's index entirely, cancelling its +// rebuild context first (round 11 SHOULD), forcing the next +// storedNamesIndex(key) to start from a fresh, never-built index. Production +// code never calls this directly (pruneOtherIndexesLocked and +// evictExcessLocked cover the two bounding cases); it exists so tests can +// force a cold index without reaching into the map's internals. +func forgetIndex(key string) { + storedIndexesMu.Lock() + defer storedIndexesMu.Unlock() + if idx, ok := storedIndexes[key]; ok { + idx.cancel() + } + delete(storedIndexes, key) + for i, k := range storedIndexesLRU { + if k == key { + storedIndexesLRU = append(storedIndexesLRU[:i], storedIndexesLRU[i+1:]...) + break + } + } +} + +// forEachIndex calls fn for every currently held index. Production code +// never needs this (each request or Warm call addresses one directory); it +// exists so tests can wait out every rebuild goroutine the suite has left in +// flight, whatever directories they touched. +func forEachIndex(fn func(*storedNames)) { + storedIndexesMu.Lock() + idxs := make([]*storedNames, 0, len(storedIndexes)) + for _, idx := range storedIndexes { + idxs = append(idxs, idx) + } + storedIndexesMu.Unlock() + for _, idx := range idxs { + fn(idx) + } +} + +// dirGeneration is the stat tuple that moves whenever a directory's entry +// set can have changed: adding, removing or renaming an entry updates its +// mtime and ctime (ctime cannot be set from user space, so a restored mtime — +// tar, rsync -a — does not hide a change), a replaced directory has another +// inode, and size is the cheap extra. dev is the device the inode lives on +// (round 9 MUST-FIX): an inode number is unique only WITHIN a device, so +// without it a bind-mount swap to another filesystem whose directory happens +// to collide on inode, size, mtime and ctime would read as the SAME +// generation. dirGenerationOf reads the tuple from a path-based Lstat result +// (used by the package's tests and by the pre-round-11 callers that still +// have only a path, never a descriptor); dirFdGeneration in dirfd_other.go +// reads the identical tuple from an already-open descriptor via fstat — the +// form every request and rebuild actually uses (round 11 MUST-FIX). +type dirGeneration struct { + modTime, changeTime time.Time + size int64 + ino uint64 + dev uint64 +} + +func (g dirGeneration) equal(o dirGeneration) bool { + return g.modTime.Equal(o.modTime) && g.changeTime.Equal(o.changeTime) && + g.size == o.size && g.ino == o.ino && g.dev == o.dev +} + +// latest is the later of the two timestamps. +func (g dirGeneration) latest() time.Time { + if g.changeTime.After(g.modTime) { + return g.changeTime + } + return g.modTime +} + +// generationSettleTime, maxRebuildAttempts, rebuildBackoff, indexClock and +// SetIndexClockForTest now live in indexclock.go (no build tag): round 13 +// gave Windows a real settle-window index too, sharing the identical clock +// and constants rather than each platform keeping its own copy. + +// extraVerifyOpened is an additional, platform-specific spelling proof run +// on the opened descriptor after the shared generation recheck passes +// (round 13). The default is a no-op: openat's identity binding +// (dirfd_other.go) plus the generation recheck above is everything Linux +// and the BSDs can prove, and nothing more is needed. darwin overrides this +// (entryname_darwin.go's init) with an F_GETPATH check of the opened +// descriptor's own basename — belt-and-suspenders on top of the same index, +// not a substitute for it. +var extraVerifyOpened = func(*os.File, string) error { return nil } + +// Warm builds the stored-name index of scriptsDir on the caller's goroutine, +// so the first scoped request finds it ready. The server calls it when it +// learns its scripts directory; it is never called on a request's behalf. +// The listing is taken after Warm was called (a rebuild already in flight is +// waited for, then Warm lists again), so the index reflects the directory as +// it was at the call. A directory that cannot be opened or listed leaves a +// failed index (scoped callers are refused as unreadable until the directory +// changes) and the failure is returned for logging. On Windows there is no +// index of this shape (storedspellings_probe_windows.go keeps its own, +// round 13) and Warm is a no-op; darwin joined this index round 13, so Warm +// behaves for it exactly as it does for Linux/BSD. +// +// Warm also keeps ONLY scriptsDir's index (round 9 SHOULD): the server calls +// Warm whenever the active scripts directory changes, so this is the point +// that knows which directory is current — every other directory's index is +// dropped (its rebuild context cancelled, round 11 SHOULD) rather than left +// to accumulate for as long as the process runs. +// +// Warm's own rebuild is never cancelled by that pruning (round 11 SHOULD): +// cancellation exists to stop an ASYNC rebuild nobody is waiting for from +// outliving the index that scheduled it, not to let a concurrent caller's +// eviction of a DIFFERENT directory silently turn this synchronous call — +// which the caller is blocked on and whose error it trusts — into a no-op. +// scriptsDir's own index is never among the ones pruneOtherIndexesLocked +// drops here, so this is only a concern for a hypothetical concurrent Warm +// of a different directory; the rebuild call below simply does not consult +// ctx, so it always runs to completion and its result is always installed. +func Warm(scriptsDir string) error { + key := filepath.Clean(scriptsDir) + idx := storedNamesIndex(key) + storedIndexesMu.Lock() + pruneOtherIndexesLocked(key) + storedIndexesMu.Unlock() + for { + idx.mu.Lock() + if !idx.building { + idx.beginRebuildLocked() + idx.mu.Unlock() + break + } + landed := idx.landed + idx.mu.Unlock() + <-landed + } + // Round 17 SHOULD: Warm's own population-sized listing must count + // against the SAME process-wide rebuildSlots bound the async path + // enforces (rebuildsemaphore.go) — otherwise the documented "at most + // maxConcurrentRebuilds concurrent listings, process-wide" claim + // (research.md) does not hold once more than one Warm call is in + // flight for different directories, e.g. two active-config-path moves + // each spawning their own async `go warmStoredScripts` call + // (mcp_code_execution.go). Unlike scheduleRebuildLocked's non-blocking, + // skip-if-busy acquire, Warm BLOCKS for a slot: it cannot skip the + // work the way an async, nobody's-waiting rebuild can — the caller is + // blocked on Warm and trusts the error it returns. Acquired here, + // OUTSIDE idx.mu (already released by the loop above), so a blocked + // acquire can never hold up another goroutine that needs idx.mu to + // make progress; what frees this acquire is some OTHER rebuild in the + // process finishing and releasing its slot, which never depends on + // idx.mu or on this goroutine. + rebuildSlots <- struct{}{} + defer func() { <-rebuildSlots }() + // backoffAfter is false: Warm is the server's own explicit request for a + // current index (at startup, or when the active scripts directory + // moves), not a request-triggered rebuild guarding against runaway + // churn — it must not spend part of the round 8 SHOULD backoff a moment + // after startup refuses the very first real change to the directory. + // cancellable is false for the reason in the doc comment above. + idx.wg.Add(1) + idx.rebuild(key, false, false) + idx.mu.Lock() + defer idx.mu.Unlock() + return idx.err +} + +// storedSpellingsOf answers, for one scoped request, whether scriptsDir holds +// an entry spelled exactly `want`, and hands back how to open and re-verify +// the winning candidate — all bound to the SINGLE directory descriptor this +// call opens (round 11 MUST-FIX; see dirfd_other.go and the package doc +// comment above). The index is validated once per request against that +// descriptor's own generation, and only an index hit is probed, so an +// absent name and a differently cased one cost the same. A directory that +// cannot be opened or listed is an error the scoped resolver reports as +// unreadable, as the administrator's directory read always has (SC-005). +// +// The returned open func opens the winning candidate relative to the same +// descriptor (openatEntry) rather than a fresh resolution of the path — the +// core of the round 11 MUST-FIX. verifyUnchanged is the post-open recheck +// (round 8 MUST-FIX, the lookup→open race): the SAME descriptor's +// generation, read once more after the open; gen-before == index.gen == +// gen-after is what proves the file the open just read is the one the index +// vouched for. closeSession releases the descriptor once the caller is done +// with it, whether or not a candidate was ever opened — callers must call it +// exactly once (resolve, in codescripts.go, defers it immediately). +func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), open func(path string) (*os.File, error), verifyUnchanged func(f *os.File, want string) error, closeSession func(), err error) { + key := filepath.Clean(scriptsDir) + + dirfd, err := openScopedDir(key) + if err != nil { + return nil, nil, nil, nil, err + } + closeSession = func() { _ = unix.Close(dirfd) } + + gen, err := fstatDirGeneration(dirfd) + if err != nil { + closeSession() + return nil, nil, nil, nil, err + } + + names, lookupErr := storedNamesFor(key, dirfd, gen) + if lookupErr != nil { + closeSession() + return nil, nil, nil, nil, lookupErr + } + + storedExactly = func(want string) (bool, error) { + if _, ok := names[want]; !ok { + return false, nil + } + if err := fstatatEntry(dirfd, want); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, err + } + return true, nil + } + open = func(path string) (*os.File, error) { + return openatEntry(dirfd, filepath.Base(path)) + } + // The SAME descriptor's own generation recheck (below) is what every + // unix platform proves; f and want are additionally threaded through + // to extraVerifyOpened, the round-13 hook darwin registers (via + // entryname_darwin.go's init) for its own belt-and-suspenders F_GETPATH + // proof on the opened descriptor — Linux/BSD leave the hook at its + // default no-op, since openat's identity binding plus the generation + // recheck is already everything they can prove. Windows has no + // directory-generation index to recheck at all — see the counterpart in + // storedspellings_probe_windows.go, which proves the spelling itself on + // f instead. + verifyUnchanged = func(f *os.File, want string) error { + cur, err := fstatDirGeneration(dirfd) + if err != nil { + return err + } + if !cur.equal(gen) { + return errIndexGenerationChanged + } + return extraVerifyOpened(f, want) + } + return storedExactly, open, verifyUnchanged, closeSession, nil +} + +// storedNamesFor returns the exact-name set of scriptsDir as the index holds +// it for THIS request's already-open descriptor and its freshly read +// generation (round 11 MUST-FIX: dirfd and gen both come from the SAME open, +// never a path lookup of their own) — never listing on the caller's behalf. +// When dirfd's generation does not equal the index's OWN generation (never +// built, behind, or a rebuild merely scheduled or in flight for it) — or the +// index is not yet SETTLED (round 9 MUST-FIX) — the request is answered as +// fail-closed as a never-built index: nil names, no error, nothing scheduled +// beyond the rebuild that (still) needs to run. +// +// Because dirfd's generation already carries the directory's device and +// inode (dirGeneration, round 9 MUST-FIX), this is also what refuses a +// request whose descriptor resolves to a DIFFERENT directory than the one +// the index was built from, even should every other field of the stamp +// happen to collide: idx.gen.equal(gen) requires the identical dev+ino, so +// an index built from directory A never authorizes a request whose dirfd +// opened directory B. +func storedNamesFor(key string, dirfd int, gen dirGeneration) (names map[string]struct{}, err error) { + idx := storedNamesIndex(key) + now := indexClock() + + idx.mu.Lock() + defer idx.mu.Unlock() + + // current is whether the index is BUILT and answers for exactly this + // generation — the necessary condition for scheduling logic below: an + // out-of-date generation always reschedules, an in-date-but-unsettled + // one reschedules at most once per window. + current := (idx.names != nil || idx.err != nil) && idx.gen.equal(gen) + + switch { + case !current: + idx.scheduleRebuildLocked(key, now) + case !idx.settled && !now.Before(idx.refreshAfter): + idx.scheduleRebuildLocked(key, now) + } + + // authorized additionally requires the index to be SETTLED (round 9 + // MUST-FIX): a matching-but-unsettled generation is refused exactly as + // a mismatched one is, because a coarse timestamp cannot rule out a + // rename that landed on the very stamp being trusted. + if !current || !idx.settled { + return nil, nil + } + return idx.names, idx.err +} + +// scheduleRebuildLocked starts the directory's ASYNC rebuild goroutine +// unless one is already in flight or the backoff since the last one has not +// elapsed (round 8 SHOULD), and opens the next refresh window either way. +// During the backoff a request's own cost is unaffected — one open, one +// fstat, answered fail-closed from whatever the index holds (or does not) — +// only a NEW rebuild goroutine is withheld. +func (idx *storedNames) scheduleRebuildLocked(key string, now time.Time) { + idx.refreshAfter = now.Add(generationSettleTime) + if idx.building { + return + } + if !idx.nextAttempt.IsZero() && now.Before(idx.nextAttempt) { + return + } + // Round 13 SHOULD (finding 5): a non-blocking acquire — every slot busy + // means SKIP this rebuild outright rather than queue behind one, so a + // request-triggered rebuild never blocks the request that scheduled it + // (this call itself is always off the request path already) and never + // piles up waiting goroutines of its own. The index stays exactly as + // stale as it was; the next request against this directory calls + // scheduleRebuildLocked again. + select { + case rebuildSlots <- struct{}{}: + default: + return + } + idx.beginRebuildLocked() + // wg.Add happens before spawnIndexRebuild hands the closure off (which + // may run it synchronously, in a test that holds rebuilds back) so + // idx.wg.Wait() is never called before the matching Add is visible. + idx.wg.Add(1) + spawnIndexRebuild(func() { + defer func() { <-rebuildSlots }() + idx.rebuild(key, true, true) + }) +} + +// beginRebuildLocked claims the single-flight slot. +func (idx *storedNames) beginRebuildLocked() { + idx.building = true + idx.landed = make(chan struct{}) +} + +// rebuild lists the directory (through dirfd_other.go's fd-bound primitives: +// round 11 MUST-FIX) and installs the result, holding no lock across the +// listing. cancellable selects whether this call honours idx.ctx (true for +// every ASYNC, request-scheduled rebuild) or always runs to completion +// (false, for Warm's own synchronous call — see Warm's doc comment for why). +// +// A change during the listing itself (list-then-stamp race) is caught by +// listScopedDirOnce's own before/after generation read on the SAME +// descriptor and retried — up to maxRebuildAttempts (round 8 SHOULD): a +// directory that never stops changing cannot keep this goroutine re-listing +// forever, nor keep Warm blocked forever. Giving up leaves whatever the LAST +// attempt installed; that attempt's own generation almost certainly no +// longer matches the directory's current one, so storedNamesFor's own check +// finds the index stale and refuses fail-closed exactly as it would a +// rebuild still in flight. +// +// When cancellable and idx.ctx is done — the index has been evicted or +// pruned since this rebuild started (round 11 SHOULD) — the loop stops at +// the next checkpoint (between attempts, and once more right before +// installing) and installs NOTHING: there is no reader left this index +// could still be wrong for, so there is no reason to pay for, or trust, a +// listing nobody will read. Ends by releasing the single-flight slot and +// closing landed either way, so a concurrent waiter (Warm, or another +// request) is never left blocked; when backoffAfter is set (every +// spawnIndexRebuild-triggered call), it also opens the backoff window +// before another rebuild of this directory may start. +func (idx *storedNames) rebuild(key string, backoffAfter, cancellable bool) { + defer idx.wg.Done() + for attempt := 1; ; attempt++ { + if cancellable && idx.ctx.Err() != nil { + idx.finishRebuild(backoffAfter) + return + } + before, after, names, listErr := listScopedDirOnce(key) + now := indexClock() + if cancellable && idx.ctx.Err() != nil { + idx.finishRebuild(backoffAfter) + return + } + if listErr == nil && attempt < maxRebuildAttempts && !before.equal(after) { + continue + } + gen := after + if listErr != nil { + gen = before + } + idx.mu.Lock() + idx.names, idx.err, idx.gen = names, listErr, gen + idx.settled = listErr == nil && now.Sub(gen.latest()) >= generationSettleTime + idx.building = false + if backoffAfter { + idx.nextAttempt = indexClock().Add(rebuildBackoff) + } + close(idx.landed) + idx.mu.Unlock() + return + } +} + +// finishRebuild releases the single-flight slot and closes landed without +// installing anything — used only when a cancellable rebuild stops early +// (round 11 SHOULD). +func (idx *storedNames) finishRebuild(backoffAfter bool) { + idx.mu.Lock() + idx.building = false + if backoffAfter { + idx.nextAttempt = indexClock().Add(rebuildBackoff) + } + close(idx.landed) + idx.mu.Unlock() +} + +// listScopedDirOnce opens key once, reads its generation, lists its entries +// through the SAME descriptor, and reads the generation once more — all +// round 11 MUST-FIX: a single open serves the generation read AND the +// listing, so a change during the listing (the list-then-stamp race) is +// caught by the two reads disagreeing, without ever resolving the path a +// second time. A variable so the tests can inject the directory-open seam's +// behaviour directly; the primitives it calls (dirfd_other.go) are +// themselves variables for finer-grained races. +var listScopedDirOnce = defaultListScopedDirOnce + +func defaultListScopedDirOnce(key string) (before, after dirGeneration, names map[string]struct{}, err error) { + dirfd, err := openScopedDir(key) + if err != nil { + return dirGeneration{}, dirGeneration{}, nil, err + } + defer func() { _ = unix.Close(dirfd) }() + + before, err = fstatDirGeneration(dirfd) + if err != nil { + return dirGeneration{}, dirGeneration{}, nil, err + } + entryNames, err := listScopedDir(dirfd, key) + if err != nil { + return before, dirGeneration{}, nil, err + } + after, err = fstatDirGeneration(dirfd) + if err != nil { + return before, dirGeneration{}, nil, err + } + names = make(map[string]struct{}, len(entryNames)) + for _, n := range entryNames { + names[n] = struct{}{} + } + return before, after, names, nil +} diff --git a/internal/codescripts/storednames_other_test.go b/internal/codescripts/storednames_other_test.go new file mode 100644 index 000000000..282cab69d --- /dev/null +++ b/internal/codescripts/storednames_other_test.go @@ -0,0 +1,1220 @@ +//go:build unix + +package codescripts + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +// simulateCaseFoldingFstatat makes the package's fstatatEntry seam behave +// like a case-insensitive, case-preserving directory lookup (APFS, NTFS, +// ext4 casefold, vfat): a name that does not exist as spelled resolves to +// the entry whose name matches it case-insensitively. The listing it +// consults is the simulation's own (os.ReadDir directly, on dir, since +// fstatatEntry only receives a bare name relative to an already-open +// descriptor), invisible to the listScopedDir seam. Installed BEFORE +// countScopedDirPrimitives when both are used, so the counters see the +// resolver's own calls and not the simulation's. +func simulateCaseFoldingFstatat(t *testing.T, dir string) { + t.Helper() + quiesceIndexRebuilds() + orig := fstatatEntry + fstatatEntry = func(dirfd int, name string) error { + err := orig(dirfd, name) + if err == nil || !errors.Is(err, fs.ErrNotExist) { + return err + } + entries, readErr := os.ReadDir(dir) + if readErr != nil { + return err + } + for _, e := range entries { + if strings.EqualFold(e.Name(), name) { + return orig(dirfd, e.Name()) + } + } + return err + } + t.Cleanup(func() { + quiesceIndexRebuilds() + fstatatEntry = orig + }) +} + +// quiesceIndexRebuilds waits for every rebuild goroutine the tests so far +// have left in flight. The package's seams (the dirfd_other.go primitives, +// listScopedDirOnce, indexClock, spawnIndexRebuild) are process-wide, so a +// helper that installs or restores one must first let any rebuild still +// reading them land. +func quiesceIndexRebuilds() { + forEachIndex(func(idx *storedNames) { + idx.mu.Lock() + building, landed := idx.building, idx.landed + idx.mu.Unlock() + if building { + <-landed + } + }) +} + +// settleStoredNamesClock moves the index clock far past any directory the +// test writes, so an index taken now counts as settled (a coarse-timestamp +// write can no longer share the recorded stamp) and is trusted until the +// directory's generation moves. Restored on cleanup. +func settleStoredNamesClock(t *testing.T) { + t.Helper() + quiesceIndexRebuilds() + orig := indexClock + indexClock = func() time.Time { return orig().Add(time.Hour) } + t.Cleanup(func() { + quiesceIndexRebuilds() + indexClock = orig + }) +} + +// warmStoredNames builds the stored-name index of dir once, with the clock +// settled, so the shared tests that count a scoped resolution's directory +// reads start from a warm index — as the server does at construction. The +// one listing is paid off the request path (pinned below), never per request. +func warmStoredNames(t *testing.T, dir string) { + t.Helper() + settleStoredNamesClock(t) + require.NoError(t, Warm(dir)) +} + +// heldRebuilds is the test's grip on the rebuild goroutines: while installed, +// a request that schedules a rebuild hands it here instead of spawning it, so +// what the request does on its OWN goroutine is exactly what the counters +// see, and the rebuild lands only when the test says so. +type heldRebuilds struct { + mu sync.Mutex + held []func() +} + +// holdIndexRebuilds installs the grip for the test's duration; whatever is +// still held at cleanup is landed so no index is left claimed. +func holdIndexRebuilds(t *testing.T) *heldRebuilds { + t.Helper() + h := &heldRebuilds{} + quiesceIndexRebuilds() + orig := spawnIndexRebuild + spawnIndexRebuild = func(rebuild func()) { + h.mu.Lock() + defer h.mu.Unlock() + h.held = append(h.held, rebuild) + } + t.Cleanup(func() { + spawnIndexRebuild = orig + h.land() + }) + return h +} + +// land runs every held rebuild on the test goroutine and reports how many +// there were — how many the requests since the last land scheduled. +func (h *heldRebuilds) land() int { + h.mu.Lock() + held := h.held + h.held = nil + h.mu.Unlock() + for _, rebuild := range held { + rebuild() + } + return len(held) +} + +// waitForIndexRebuild blocks until the rebuild goroutine of dir, if one is in +// flight, has landed: the seam a test waits on instead of sleeping. +func waitForIndexRebuild(t *testing.T, dir string) { + t.Helper() + idx := storedNamesIndex(filepath.Clean(dir)) + idx.mu.Lock() + building, landed := idx.building, idx.landed + idx.mu.Unlock() + if !building { + return + } + select { + case <-landed: + case <-time.After(10 * time.Second): + t.Fatalf("%s: the index rebuild did not land", dir) + } +} + +// requireScopedNotFound asserts the ordinary non-disclosing not-found refusal. +func requireScopedNotFound(t *testing.T, err error) { + t.Helper() + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + assert.True(t, notFound.Undisclosed, "the refusal is the ordinary non-disclosing form") +} + +// primCounts tallies the round 11 fd-bound primitives (dirfd_other.go) a +// scoped resolution or a rebuild goroutine performs: opens (openScopedDir), +// fstats (fstatDirGeneration — twice for a hit: before the lookup and after +// the open), fstatats (fstatatEntry — the candidate's own probe) and openats +// (openatEntry — the actual read). lists counts listScopedDir, the rebuild +// goroutine's own readdir. These replace the readDir/lstat counters +// countDirectoryPrimitives (codescripts_test.go) still uses for the +// administrator's unchanged, path-based decision. +type primCounts struct { + opens, fstats, fstatats, openats, lists int +} + +// countScopedDirPrimitives routes every round 11 fd-bound primitive through +// counters for the duration of the test, so a test can pin the EXACT +// bounded cost of one request — one open, two fstats, one fstatat, one +// openat for a hit; fewer for a miss, which never probes or opens a +// candidate — whatever the directory holds. Any rebuild still in flight +// lands first so its own calls do not pollute what the counted request +// itself performs. +func countScopedDirPrimitives(t *testing.T) *primCounts { + t.Helper() + c := &primCounts{} + quiesceIndexRebuilds() + origOpen, origFstat, origFstatat, origOpenat, origList := openScopedDir, fstatDirGeneration, fstatatEntry, openatEntry, listScopedDir + openScopedDir = func(path string) (int, error) { + c.opens++ + return origOpen(path) + } + fstatDirGeneration = func(fd int) (dirGeneration, error) { + c.fstats++ + return origFstat(fd) + } + fstatatEntry = func(dirfd int, name string) error { + c.fstatats++ + return origFstatat(dirfd, name) + } + openatEntry = func(dirfd int, name string) (*os.File, error) { + c.openats++ + return origOpenat(dirfd, name) + } + listScopedDir = func(dirfd int, path string) ([]string, error) { + c.lists++ + return origList(dirfd, path) + } + t.Cleanup(func() { + quiesceIndexRebuilds() + openScopedDir, fstatDirGeneration, fstatatEntry, openatEntry, listScopedDir = origOpen, origFstat, origFstatat, origOpenat, origList + }) + return c +} + +// lookupStoredNamesForTest is storedNamesFor for a self-contained, +// throwaway lookup: it opens the directory, reads its generation, calls +// storedNamesFor, and closes the descriptor itself. Production code never +// needs this — every real request already holds the session +// storedSpellingsOf opened for it — but the package's own tests, which only +// want to inspect what the index currently answers, do. +func lookupStoredNamesForTest(t *testing.T, dir string) map[string]struct{} { + t.Helper() + key := filepath.Clean(dir) + fd, err := openScopedDir(key) + require.NoError(t, err) + defer func() { _ = unix.Close(fd) }() + gen, err := fstatDirGeneration(fd) + require.NoError(t, err) + names, err := storedNamesFor(key, fd, gen) + require.NoError(t, err) + return names +} + +// TestResolveScoped_OnAFoldingDirectory (Spec 105 FR-012, codex r3 #1, r4 #1 +// and r5 #1): Linux has no single-entry call that reports an entry's stored +// spelling, so on a case-folding mount (ext4 casefold, vfat, a bind mount +// from a case-insensitive host) a probe for `backdoor.js` finds `backdoor.JS` +// — a file the listing and the administrator's Resolve reject. Round 4 +// settled the spelling by a listing paid when the probe hit, which made the +// PRESENCE of a differently cased entry cost O(directory) while absence cost +// O(1): a timing oracle on the stored names (round 5). The contract now: the +// scoped resolver answers from the directory's stored-name index, so a +// folded spelling is refused with the ordinary non-disclosing not-found, an +// exact name runs for every caller, and no request lists the directory — +// warm or cold. Since round 11 the index is validated and probed through a +// single retained directory descriptor per request (dirfd_other.go); a +// folded name is refused here purely because it is not a KEY in the index's +// exact-spelling set (built from the real on-disk names), so it never even +// reaches the candidate probe — the fold simulation matters for the +// STALE-index scenario below, where a name that WAS a key must still be +// refused. +func TestResolveScoped_OnAFoldingDirectory(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "backdoor.JS", "({pwned: true})") + writeScript(t, dir, "exact.js", "({exact: true})") + simulateCaseFoldingFstatat(t, dir) + warmStoredNames(t, dir) + + t.Run("a folded spelling is not a stored script, and settling it lists nothing", func(t *testing.T) { + c := countScopedDirPrimitives(t) + src, _, err := ResolveScoped(dir, "backdoor", "") + requireScopedNotFound(t, err) + assert.NotContains(t, string(src), "pwned") + assert.Equal(t, 0, c.lists, "a warm index answers the fold without a listing (codex r5 #1)") + assert.Equal(t, 1, c.opens, "one directory open validates the index") + assert.Equal(t, 1, c.fstats, "one fstat reads its generation; the candidate itself is never probed") + assert.Equal(t, 0, c.fstatats, "\"backdoor.js\" is not a key of the index built from the real on-disk name") + + // The administrator's directory read agrees: byte-for-byte, .JS is + // not an extension of a stored script. + var notFound *NotFoundError + _, _, err = Resolve(dir, "backdoor", "") + require.True(t, errors.As(err, ¬Found)) + assert.False(t, notFound.Undisclosed) + }) + + t.Run("an exactly spelled script runs for scoped callers and administrators alike", func(t *testing.T) { + c := countScopedDirPrimitives(t) + src, lang, err := ResolveScoped(dir, "exact", "") + require.NoError(t, err, "a correctly named script must not be refused to an agent token (codex r4 #1)") + assert.Equal(t, "({exact: true})", string(src)) + assert.Equal(t, LanguageJavaScript, lang) + assert.Equal(t, 0, c.lists) + assert.Equal(t, 1, c.opens, "the SINGLE directory descriptor this request opens (round 11 MUST-FIX)") + assert.Equal(t, 2, c.fstats, "the generation read before the lookup and the post-open recheck after (round 8 MUST-FIX), both on that same descriptor") + assert.Equal(t, 1, c.fstatats, "the hit's own candidate probe") + assert.Equal(t, 1, c.openats, "the open, relative to the same descriptor") + + src, lang, err = Resolve(dir, "exact", "") + require.NoError(t, err) + assert.Equal(t, "({exact: true})", string(src)) + assert.Equal(t, LanguageJavaScript, lang) + }) + + t.Run("an absent name and a present case-variant cost the same, cold and warm", func(t *testing.T) { + held := holdIndexRebuilds(t) + cost := func(name string) (opens, fstats int) { + forgetIndex(filepath.Clean(dir)) // cold + c := countScopedDirPrimitives(t) + _, _, err := ResolveScoped(dir, name, "") + requireScopedNotFound(t, err) + assert.Equal(t, 0, c.lists, "%s: a cold request lists nothing itself (codex r6 #1)", name) + assert.Equal(t, 1, held.land(), "%s: it schedules the one rebuild", name) + assert.Equal(t, 1, c.lists, "%s: which is the one listing, off the request path", name) + cOpens, cFstats := c.opens, c.fstats + _, _, err = ResolveScoped(dir, name, "") + requireScopedNotFound(t, err) + assert.Equal(t, 1, c.lists, "%s: the second request finds the index warm", name) + assert.Equal(t, 0, held.land(), "%s: and schedules nothing", name) + return c.opens - cOpens, c.fstats - cFstats + } + absentOpens, absentFstats := cost("missing") + variantOpens, variantFstats := cost("backdoor") + assert.Equal(t, absentOpens, variantOpens, "the open count does not depend on the requested name") + assert.Equal(t, absentFstats, variantFstats, "nor does the fstat count") + }) + + t.Run("the index holds the stored spelling, so the fold is settled by an exact lookup", func(t *testing.T) { + names := lookupStoredNamesForTest(t, dir) + assert.Contains(t, names, "backdoor.JS") + assert.NotContains(t, names, "backdoor.js") + assert.Contains(t, names, "exact.js") + }) +} + +// TestResolveScoped_StaleIndexRefusesARenamedEntry (Spec 105 FR-012, codex r7 +// #1 / round 8 MUST-FIX): earlier rounds scheduled a rebuild when a request +// found the index behind the directory's generation, but still answered +// from the index as it stood before — a stale index that once listed an +// entry under an EARLIER spelling stayed good enough to authorize it. On a +// case-folding mount that executes the wrong file: warm the index with +// `report.js`, then rename it to `REPORT.JS` (a real rename, so the +// directory's generation genuinely moves); the stale index still contains +// `report.js`, and that entry's own probe — simulated through the +// fstatatEntry seam so the fold is exercised on the case-sensitive +// filesystems CI runs on — folds onto the renamed file and succeeds, which +// round 7 trusted as a hit. The fix: the index answers ONLY for the +// generation it was built against, so a request landing while the rebuild +// is merely scheduled is refused exactly like a never-built index, without +// ever probing the candidate the stale index used to hold. +func TestResolveScoped_StaleIndexRefusesARenamedEntry(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "report.js", "({pwned: true})") + simulateCaseFoldingFstatat(t, dir) + warmStoredNames(t, dir) + held := holdIndexRebuilds(t) + + outliveStamp(t, dir) + before, err := lstat(dir) + require.NoError(t, err) + require.NoError(t, os.Rename(filepath.Join(dir, "report.js"), filepath.Join(dir, "REPORT.JS"))) + waitForGenerationChange(t, dir, dirGenerationOf(before)) + + c := countScopedDirPrimitives(t) + src, _, err := ResolveScoped(dir, "report", "") + requireScopedNotFound(t, err) + assert.Nil(t, src, "the stale index must never authorize the renamed file, whatever its own probe folds onto") + assert.Equal(t, 0, c.lists, "the refusal lists nothing (it is fail-closed on the generation mismatch alone)") + assert.Equal(t, 1, c.opens, "one directory open") + assert.Equal(t, 1, c.fstats, "one fstat decides staleness; the stale index's candidate is never probed") + assert.Equal(t, 0, c.fstatats) + assert.Equal(t, 1, held.land(), "the rename moved the generation: one rebuild is scheduled") + assert.Equal(t, 1, c.lists, "which is the one listing, off the request path") + + // The rebuild has landed: the index now holds REPORT.JS, not report.js. + // The old spelling is still refused — never executed — for the same + // reason the administrator's byte-for-byte decision refuses it too. + src, _, err = ResolveScoped(dir, "report", "") + requireScopedNotFound(t, err) + assert.Nil(t, src) + + var notFound *NotFoundError + _, _, err = Resolve(dir, "report", "") + require.True(t, errors.As(err, ¬Found)) + assert.False(t, notFound.Undisclosed) +} + +// TestResolveScoped_GenerationChangeBetweenLookupAndOpenRefuses (round 8 +// MUST-FIX, the lookup→open race): an index hit is re-probed by the +// candidate's own no-follow stat, but neither that nor a successful +// no-follow open proves the file just opened is the one the index vouched +// for — a write landing between the probe and the open can leave a +// DIFFERENT file occupying the exact name for the descriptor's entire +// lifetime. The directory's generation (round 11: read from the SAME +// retained descriptor the whole request uses) is read once more after the +// open and must still equal the one read before the lookup; a mismatch +// closes the descriptor and refuses. The race is simulated at the seam +// where it actually lands in production: right after openatEntry succeeds +// and before the post-open recheck runs. +func TestResolveScoped_GenerationChangeBetweenLookupAndOpenRefuses(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "({original: true})") + warmStoredNames(t, dir) + + origOpenat := openatEntry + var races int + t.Cleanup(func() { openatEntry = origOpenat }) + openatEntry = func(dirfd int, name string) (*os.File, error) { + f, err := origOpenat(dirfd, name) + if err == nil && name == "alpha.js" && races == 0 { + races++ + // Races the open: a write lands after the index vouched for the + // candidate and the open succeeded, but before the descriptor is + // trusted. + require.NoError(t, os.Remove(filepath.Join(dir, "alpha.js"))) + require.NoError(t, os.WriteFile(filepath.Join(dir, "alpha.js"), []byte("({swapped: true})"), 0o644)) + // A same-name remove-then-recreate can land on the exact same + // coarse directory timestamp as the original write (a container + // filesystem observed to do this even at nanosecond + // "resolution"): force the generation forward so it is + // unambiguously the write's, not the clock's granularity, that + // the recheck must catch. + require.NoError(t, os.Chtimes(dir, time.Now(), time.Now().Add(time.Second))) + } + return f, err + } + + src, _, err := ResolveScoped(dir, "alpha", "") + requireScopedNotFound(t, err) + assert.Nil(t, src, "a file swapped in during the open's own window must never be read, original or swapped content alike") + assert.Equal(t, 1, races, "the race must actually have run for this to prove anything") +} + +// TestResolveScoped_ColdRequestCostIsIndependentOfDirectorySize (codex r6 +// #1): the FIRST scoped request against a directory — before any index +// exists — must cost the same for an empty directory and for one holding ten +// thousand scripts. It lists nothing on its own goroutine, performs the same +// bounded number of directory primitives and is refused fail-closed; the +// listing happens when the rebuild lands, and the next request is answered +// from it. +func TestResolveScoped_ColdRequestCostIsIndependentOfDirectorySize(t *testing.T) { + empty := t.TempDir() + crowded := t.TempDir() + for i := 0; i < 10_000; i++ { + writeScript(t, crowded, fmt.Sprintf("script-%05d.js", i), "1") + } + settleStoredNamesClock(t) + held := holdIndexRebuilds(t) + + probe := func(dir string) (opens, fstats int) { + forgetIndex(filepath.Clean(dir)) // cold: never warmed + c := countScopedDirPrimitives(t) + _, _, err := ResolveScoped(dir, "script-00042", "") + requireScopedNotFound(t, err) + assert.Equal(t, 0, c.lists, "%s: a cold request must not list on its own goroutine", dir) + // Snapshot before landing the rebuild: the rebuild's own listing + // performs its own open/fstat calls through the SAME counters, which + // must not be attributed to the request that merely scheduled it. + opens, fstats = c.opens, c.fstats + assert.Equal(t, 1, held.land(), "%s: the cold request schedules exactly one rebuild", dir) + return opens, fstats + } + + emptyOpens, emptyFstats := probe(empty) + crowdedOpens, crowdedFstats := probe(crowded) + assert.Equal(t, emptyOpens, crowdedOpens, "the number of directory opens is independent of the directory's contents") + assert.Equal(t, emptyFstats, crowdedFstats, "nor does the fstat count depend on it") + assert.Equal(t, 1, emptyOpens, "the request's own directory open") + + // Landed: the script that was refused a moment ago now runs, with no + // listing on the request goroutine and none scheduled. + c := countScopedDirPrimitives(t) + src, _, err := ResolveScoped(crowded, "script-00042", "") + require.NoError(t, err, "after the rebuild lands the same request executes") + assert.Equal(t, "1", string(src)) + assert.Equal(t, 0, c.lists) + assert.Equal(t, 1, c.opens, "the single retained descriptor (round 11 MUST-FIX)") + assert.Equal(t, 2, c.fstats, "the generation read before the lookup and the post-open recheck (round 8 MUST-FIX)") + assert.Equal(t, 1, c.fstatats, "the hit's own candidate probe") + assert.Equal(t, 1, c.openats) + assert.Equal(t, 0, held.land()) +} + +// TestStoredNames_GenerationChangeRebuildsOffTheRequestPath pins the cost +// rule of the index: an unchanged directory is never listed again, however +// many requests are answered from it and whatever they ask for; a change (an +// entry added) is one asynchronous listing that no request performs — the +// request that notices it is refused fail-closed and the next one sees the +// new script. +func TestStoredNames_GenerationChangeRebuildsOffTheRequestPath(t *testing.T) { + t.Run("held: the request lists nothing and the landed rebuild serves the next", func(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + warmStoredNames(t, dir) + held := holdIndexRebuilds(t) + c := countScopedDirPrimitives(t) + + for i, name := range []string{"alpha", "missing", "ALPHA"} { + for j := 0; j < 20; j++ { + _, _, _ = ResolveScoped(dir, name, "") + } + assert.Equal(t, 0, c.lists, "%d: an unchanged directory is never listed", i) + assert.Equal(t, 0, held.land(), "%d: nor is a rebuild scheduled", i) + } + + outliveStamp(t, dir) + before, err := lstat(dir) + require.NoError(t, err) + writeScript(t, dir, "beta.ts", "1") + waitForGenerationChange(t, dir, dirGenerationOf(before)) + + c.opens, c.fstats = 0, 0 + _, _, err = ResolveScoped(dir, "beta", "") + requireScopedNotFound(t, err) // fail closed until the rebuild lands + assert.Equal(t, 0, c.lists, "the request that finds the generation moved lists nothing itself (codex r6 #1)") + assert.Equal(t, 1, c.opens, "one directory open, no candidate probe") + assert.Equal(t, 1, held.land(), "it schedules the one rebuild") + assert.Equal(t, 1, c.lists, "which is the one listing") + + src, lang, err := ResolveScoped(dir, "beta", "") + require.NoError(t, err, "the script added is found once the rebuild has landed") + assert.Equal(t, "1", string(src)) + assert.Equal(t, LanguageTypeScript, lang) + assert.Equal(t, 1, c.lists) + assert.Equal(t, 0, held.land(), "the directory is warm again") + }) + + t.Run("live: the rebuild goroutine lands and the next request sees the script", func(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + warmStoredNames(t, dir) + + outliveStamp(t, dir) + before, err := lstat(dir) + require.NoError(t, err) + writeScript(t, dir, "beta.ts", "1") + waitForGenerationChange(t, dir, dirGenerationOf(before)) + + c := countScopedDirPrimitives(t) + _, _, err = ResolveScoped(dir, "beta", "") + requireScopedNotFound(t, err) + waitForIndexRebuild(t, dir) + assert.Equal(t, 1, c.lists, "the rebuild is the one listing") + + src, _, err := ResolveScoped(dir, "beta", "") + require.NoError(t, err, "a script added to the directory is callable after the rebuild lands") + assert.Equal(t, "1", string(src)) + assert.Equal(t, 1, c.lists) + + // The administrator's directory read never waited for anything. + _, _, err = Resolve(dir, "beta", "") + require.NoError(t, err) + }) +} + +// outliveStamp sleeps until the directory's latest stamp is +// generationSettleTime old, so the next write lands on a later stamp whatever +// the filesystem's timestamp granularity (Linux stamps files with the coarse +// tick clock, so a write in the same tick as the index's listing would not +// move the generation — the guarantee the index itself relies on). Uses the +// package's path-based dirGenerationOf/lstat reader (dirgeneration_ctim.go / +// dirgeneration_ctimespec.go), unchanged by round 11 — a convenience for +// test bookkeeping only, never on the request or rebuild path. +func outliveStamp(t *testing.T, dir string) { + t.Helper() + info, err := lstat(dir) + require.NoError(t, err) + time.Sleep(time.Until(dirGenerationOf(info).latest().Add(generationSettleTime))) +} + +// waitForGenerationChange confirms the directory's stamp moved with the +// write (it returns at once on every filesystem this test has met); a mount +// whose stamp never moves cannot pin the change count and is skipped. +func waitForGenerationChange(t *testing.T, dir string, was dirGeneration) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + info, err := lstat(dir) + require.NoError(t, err) + if !dirGenerationOf(info).equal(was) { + return + } + if time.Now().After(deadline) { + t.Skipf("%s: the directory's stamp did not move after a write", dir) + } + time.Sleep(20 * time.Millisecond) + } +} + +// TestStoredNames_RemovedScriptFailsClosedBeforeTheRebuildLands: a stale +// index — its rebuild scheduled by a generation change but not yet landed — +// is refused exactly as a never-built index is (round 8 MUST-FIX): a script +// removed after the last listing is refused at once, before the rebuild +// that will drop it from the index has even STARTED to run, and WITHOUT +// probing the candidate the stale index used to hold. +func TestStoredNames_RemovedScriptFailsClosedBeforeTheRebuildLands(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + warmStoredNames(t, dir) + held := holdIndexRebuilds(t) + + outliveStamp(t, dir) + before, err := lstat(dir) + require.NoError(t, err) + require.NoError(t, os.Remove(filepath.Join(dir, "alpha.js"))) + waitForGenerationChange(t, dir, dirGenerationOf(before)) + c := countScopedDirPrimitives(t) + _, _, err = ResolveScoped(dir, "alpha", "") + requireScopedNotFound(t, err) + assert.Equal(t, 0, c.lists, "the refusal lists nothing") + assert.Equal(t, 1, c.opens, "the directory open alone decides staleness; the stale index's candidate is never probed (round 8 MUST-FIX)") + assert.Equal(t, 0, c.fstatats) + + assert.Equal(t, 1, held.land(), "the removal moved the generation: one rebuild") + names := lookupStoredNamesForTest(t, dir) + assert.NotContains(t, names, "alpha.js") + _, _, err = ResolveScoped(dir, "alpha", "") + requireScopedNotFound(t, err) +} + +// TestStoredNames_UnsettledIndexRefreshesAtMostOncePerWindow pins the +// coarse-timestamp guard: an index taken within generationSettleTime of the +// directory's stamp cannot rule out a write in the same tick, so requests +// keep scheduling a refresh — at most one per window, off the request path, +// for every name alike — and once a listing lands past the window the index +// is trusted until the stamp moves. The request's own cost never changes. +func TestStoredNames_UnsettledIndexRefreshesAtMostOncePerWindow(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + info, err := lstat(dir) + require.NoError(t, err) + stamp := dirGenerationOf(info).latest() + + quiesceIndexRebuilds() + orig := indexClock + t.Cleanup(func() { indexClock = orig }) + indexClock = func() time.Time { return stamp.Add(generationSettleTime / 2) } + held := holdIndexRebuilds(t) + require.NoError(t, Warm(dir), "warmed inside the window: the index is not settled") + c := countScopedDirPrimitives(t) + + // requests issues scoped misses and pins each one's own cost: no listing, + // one directory open — the landed rebuilds' calls are counted between. + requests := func(label string, names ...string) { + for i, name := range names { + lists, opens := c.lists, c.opens + _, _, err := ResolveScoped(dir, name, "") + requireScopedNotFound(t, err) + assert.Equal(t, lists, c.lists, "%s %d: a request never lists", label, i) + assert.Equal(t, opens+1, c.opens, "%s %d: one directory open per request", label, i) + } + } + + // "alpha" is a genuinely stored script — its generation matches the + // index's the whole time — yet it must be refused exactly like "missing" + // and "gamma" until the index is SETTLED (round 9 MUST-FIX): a matching + // generation alone cannot rule out a coarse-timestamp rename that landed + // on the very stamp being trusted, so an unsettled index authorizes + // nothing, hit or miss alike. + requests("inside the window", "alpha", "missing", "gamma", "missing") + assert.Equal(t, 1, held.land(), "the unsettled index schedules ONE refresh per window, not one per request") + assert.Equal(t, 1, c.lists) + requests("still inside", "alpha", "missing", "gamma") + assert.Equal(t, 0, held.land(), "the window is open until it elapses") + + // The refresh window elapsed but the stamp is still too young: one more. + indexClock = func() time.Time { return stamp.Add(generationSettleTime/2 + generationSettleTime) } + requests("next window", "alpha", "missing") + assert.Equal(t, 1, held.land(), "the next window schedules one more refresh") + assert.Equal(t, 2, c.lists) + requests("settled", "missing", "gamma", "missing") + assert.Equal(t, 0, held.land(), "the listing landed past the stamp's settle time: the index is trusted") + assert.Equal(t, 2, c.lists) + + // Only now — genuinely settled, not merely gen-matching — does the real + // hit run (round 9 MUST-FIX). + src, _, err := ResolveScoped(dir, "alpha", "") + require.NoError(t, err, "once the index is settled, an exact hit runs") + assert.Equal(t, "1", string(src)) +} + +// TestStoredNames_RebuildAttemptsAreBounded (round 8 SHOULD): a directory +// whose generation moves on every observation — as another process +// continuously renaming an entry would leave it — must not keep a rebuild +// goroutine re-listing forever, and must not keep Warm blocked forever +// either. rebuild gives up after maxRebuildAttempts listings whatever the +// directory keeps doing next; what the last attempt installed simply goes +// stale against the directory's true current generation, and the next +// request's own check (the MUST-FIX rule above) refuses it rather than this +// loop spinning to prove something it never can. The churn is simulated at +// fstatDirGeneration — called twice per listing attempt by listScopedDirOnce +// (round 11 MUST-FIX), once before the listing and once after — advancing +// the directory's own mtime on every call so the two never agree within one +// attempt. +func TestStoredNames_RebuildAttemptsAreBounded(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + quiesceIndexRebuilds() + + origFstat, origList := fstatDirGeneration, listScopedDir + var fstats, lists int + t.Cleanup(func() { fstatDirGeneration, listScopedDir = origFstat, origList }) + fstatDirGeneration = func(fd int) (dirGeneration, error) { + fstats++ + // Simulate another process continuously changing the directory: its + // own generation moves on every observation, so the before/after + // check listScopedDirOnce performs around the listing can never + // confirm stability. + require.NoError(t, os.Chtimes(dir, time.Now(), time.Now().Add(time.Duration(fstats)*time.Second))) + return origFstat(fd) + } + listScopedDir = func(dirfd int, path string) ([]string, error) { + lists++ + return origList(dirfd, path) + } + + done := make(chan error, 1) + go func() { done <- Warm(dir) }() + select { + case err := <-done: + require.NoError(t, err, "a continuously changing directory must not fail Warm outright") + case <-time.After(10 * time.Second): + t.Fatal("Warm did not return against a continuously changing directory (round 8 SHOULD)") + } + assert.Equal(t, maxRebuildAttempts, lists, "one rebuild lists at most maxRebuildAttempts times, however long the directory keeps changing") +} + +// TestStoredNames_RebuildBackoffThrottlesReschedules (round 8 SHOULD): once +// a rebuild ends — landing cleanly or giving up after maxRebuildAttempts — +// the next one for the same directory may not start until rebuildBackoff has +// passed, whatever the request rate: without this, a directory that changes +// on every request would let scheduleRebuildLocked spawn a fresh rebuild the +// instant the bounded one above gives up, resuming the same unbounded +// listing cost one goroutine later. A request inside the backoff still +// costs the same bounded directory primitives and answers fail-closed from +// whatever the index holds (or does not); only the new rebuild goroutine is +// withheld. +func TestStoredNames_RebuildBackoffThrottlesReschedules(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + warmStoredNames(t, dir) + + quiesceIndexRebuilds() + orig := indexClock + t.Cleanup(func() { indexClock = orig }) + base := orig() + indexClock = func() time.Time { return base } + + outliveStamp(t, dir) + before, err := lstat(dir) + require.NoError(t, err) + writeScript(t, dir, "beta.ts", "1") + waitForGenerationChange(t, dir, dirGenerationOf(before)) + + held := holdIndexRebuilds(t) + _, _, err = ResolveScoped(dir, "beta", "") + requireScopedNotFound(t, err) + assert.Equal(t, 1, held.land(), "the generation change schedules the first rebuild") + // indexClock is still `base`: rebuild just set nextAttempt to + // base+rebuildBackoff. + + outliveStamp(t, dir) + before2, err := lstat(dir) + require.NoError(t, err) + writeScript(t, dir, "gamma.ts", "1") + waitForGenerationChange(t, dir, dirGenerationOf(before2)) + + _, _, err = ResolveScoped(dir, "gamma", "") + requireScopedNotFound(t, err) + assert.Equal(t, 0, held.land(), "a request inside the backoff window schedules nothing, though the generation moved again") + + indexClock = func() time.Time { return base.Add(rebuildBackoff) } + _, _, err = ResolveScoped(dir, "gamma", "") + requireScopedNotFound(t, err) + assert.Equal(t, 1, held.land(), "past the backoff, the still-unresolved generation mismatch schedules again") +} + +// TestStoredNames_RebuildSlotsBoundConcurrency (round 13 SHOULD, finding 5): +// no more than maxConcurrentRebuilds ASYNC rebuild goroutines may run at +// once, PROCESS-WIDE across every directory's index — a rebuild that cannot +// acquire a slot is SKIPPED outright, not queued behind one, so it never +// blocks the request that scheduled it and never itself piles up waiting. +// holdIndexRebuilds captures each scheduled rebuild's closure instead of +// running it, which — because scheduleRebuildLocked acquires its slot +// SYNCHRONOUSLY before handing the closure to spawnIndexRebuild — holds that +// slot consumed for exactly as long as the closure goes unlanded, without +// needing a real goroutine parked mid-listing. +func TestStoredNames_RebuildSlotsBoundConcurrency(t *testing.T) { + settleStoredNamesClock(t) + held := holdIndexRebuilds(t) + + busy := make([]string, maxConcurrentRebuilds) + for i := range busy { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + busy[i] = dir + _, _, err := ResolveScoped(dir, "alpha", "") + requireScopedNotFound(t, err) + } + + third := t.TempDir() + writeScript(t, third, "alpha.js", "1") + _, _, err := ResolveScoped(third, "alpha", "") + requireScopedNotFound(t, err) + + thirdIdx := storedNamesIndex(filepath.Clean(third)) + thirdIdx.mu.Lock() + building := thirdIdx.building + thirdIdx.mu.Unlock() + assert.False(t, building, "every rebuild slot is already held by another directory: this rebuild must be skipped, not queued") + + assert.Equal(t, maxConcurrentRebuilds, held.land(), + "exactly the directories that could acquire a slot were scheduled — the third was skipped, not merely deferred") + for _, dir := range busy { + waitForIndexRebuild(t, dir) + } + + // A slot is free again: the third directory's own next request finally + // schedules its rebuild, and this time it can complete. + _, _, err = ResolveScoped(third, "alpha", "") + requireScopedNotFound(t, err) // the index has not landed yet, so this request still answers fail-closed + assert.Equal(t, 1, held.land(), "the previously-skipped rebuild is scheduled now that a slot is free") + waitForIndexRebuild(t, third) + + names := lookupStoredNamesForTest(t, third) + assert.Contains(t, names, "alpha.js", "the rebuild that finally acquired a slot lands normally") +} + +// TestStoredNames_WarmListsAfterAnInFlightRebuild: Warm is the server's +// promise that the index reflects the directory as it was when Warm was +// called, so a rebuild already in flight — which may have listed before the +// latest write — is waited for and then Warm lists again. +func TestStoredNames_WarmListsAfterAnInFlightRebuild(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + settleStoredNamesClock(t) + held := holdIndexRebuilds(t) + c := countScopedDirPrimitives(t) + + _, _, err := ResolveScoped(dir, "alpha", "") + requireScopedNotFound(t, err) // cold: the rebuild is scheduled and held + writeScript(t, dir, "beta.ts", "1") + + warmed := make(chan error, 1) + go func() { warmed <- Warm(dir) }() + select { + case err := <-warmed: + t.Fatalf("Warm returned %v while the rebuild it must wait for was still held", err) + case <-time.After(50 * time.Millisecond): + } + assert.Equal(t, 1, held.land(), "the held rebuild lands") + require.NoError(t, <-warmed) + assert.Equal(t, 2, c.lists, "Warm listed again after the in-flight rebuild landed") + + src, _, err := ResolveScoped(dir, "beta", "") + require.NoError(t, err, "the script written before Warm is in the index Warm returned") + assert.Equal(t, "1", string(src)) + assert.Equal(t, 0, held.land()) +} + +// TestStoredNames_WarmBlocksOnRebuildSlots (round 17 SHOULD, finding 1): +// Warm's own synchronous, population-sized listing must count against the +// SAME process-wide rebuildSlots bound the async path enforces +// (rebuildsemaphore.go) — otherwise two concurrent Warm calls for different +// directories (e.g. two active-config-path moves, each spawning its own +// async warmStoredScripts goroutine per mcp_code_execution.go) could run an +// unbounded number of listings alongside the async rebuilds the semaphore is +// meant to cap. The semaphore's capacity is temporarily reduced to 1 (a +// seam: rebuildSlots is swapped for the test and restored on cleanup) so a +// single held listing is enough to prove the second Warm call BLOCKS on the +// slot rather than racing ahead unbounded, and unblocks once the first +// Warm's slot is released — never deadlocking, since Warm never holds +// idx.mu while blocked acquiring the slot (see Warm's own comment). +func TestStoredNames_WarmBlocksOnRebuildSlots(t *testing.T) { + quiesceIndexRebuilds() + settleStoredNamesClock(t) + origSlots := rebuildSlots + rebuildSlots = make(chan struct{}, 1) + t.Cleanup(func() { rebuildSlots = origSlots }) + + dirA := t.TempDir() + writeScript(t, dirA, "alpha.js", "1") + dirB := t.TempDir() + writeScript(t, dirB, "beta.js", "1") + + // Gate dirA's listing so the test controls exactly when its rebuild — + // and with it, the sole rebuildSlots slot — completes. + gate := make(chan struct{}) + entered := make(chan struct{}, 1) + origList := listScopedDirOnce + t.Cleanup(func() { listScopedDirOnce = origList }) + listScopedDirOnce = func(key string) (dirGeneration, dirGeneration, map[string]struct{}, error) { + if key == filepath.Clean(dirA) { + entered <- struct{}{} + <-gate + } + return origList(key) + } + + doneA := make(chan error, 1) + go func() { doneA <- Warm(dirA) }() + <-entered // dirA now holds the sole rebuildSlots slot, blocked mid-listing + + doneB := make(chan error, 1) + go func() { doneB <- Warm(dirB) }() + + // dirB's Warm claims its OWN index's building flag immediately (it does + // not contend with dirA on idx.mu — different indexes) but must block + // acquiring the shared slot, so it must not return yet. + select { + case err := <-doneB: + t.Fatalf("Warm(dirB) returned (err=%v) while the sole rebuildSlots slot was held by dirA's in-flight rebuild — the semaphore did not bound it", err) + case <-time.After(100 * time.Millisecond): + } + idxB := storedNamesIndex(filepath.Clean(dirB)) + idxB.mu.Lock() + buildingB := idxB.building + idxB.mu.Unlock() + assert.True(t, buildingB, "dirB's Warm has claimed its own index's building flag while waiting on the slot") + + close(gate) // release dirA's listing; its slot frees once its Warm returns + require.NoError(t, <-doneA) + require.NoError(t, <-doneB, "dirB's Warm proceeds once dirA's slot is released") + + namesB := lookupStoredNamesForTest(t, dirB) + assert.Contains(t, namesB, "beta.js", "dirB's rebuild ran and landed once it finally acquired the slot") +} + +// TestStoredNames_UnlistableDirectoryRefusesScopedCallers: a scripts +// directory the process cannot read is refused with the non-disclosing +// unreadable form — no path, no OS error — on the very first request, cold +// or warm, whatever the index holds: the request's own constant-cost open of +// the directory decides it, exactly where the administrator's directory read +// refuses (SC-005). +func TestStoredNames_UnlistableDirectoryRefusesScopedCallers(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root: directory permissions are not enforced") + } + scriptsDir := filepath.Join(t.TempDir(), "scripts") + writeScript(t, scriptsDir, "known.js", "1") + require.NoError(t, os.Chmod(scriptsDir, 0o111)) + t.Cleanup(func() { _ = os.Chmod(scriptsDir, 0o755) }) + + src, _, err := ResolveScoped(scriptsDir, "known", "") + require.Nil(t, src) + var invalid *InvalidError + require.True(t, errors.As(err, &invalid), "want *InvalidError, got %T: %v", err, err) + assert.True(t, invalid.Undisclosed) + assert.Equal(t, ReasonUnreadable, invalid.Reason) + assert.NotContains(t, err.Error(), scriptsDir) + assert.NotContains(t, err.Error(), "permission denied") + + _, _, err = Resolve(scriptsDir, "known", "") + require.True(t, errors.As(err, &invalid)) + assert.Equal(t, ReasonUnreadable, invalid.Reason, "the administrator is refused for the same reason") + + err = Warm(scriptsDir) + require.Error(t, err, "Warm reports the failure for the server's log") + assert.True(t, errors.Is(err, fs.ErrPermission)) +} + +// TestDirGeneration_DeviceIsPartOfIdentity (round 9 MUST-FIX): an inode +// number is unique only WITHIN its device, so two directories on different +// devices can legitimately share an inode, size and both timestamps — a +// bind-mount swap from one filesystem to another is exactly this scenario. +// Without the device in the tuple such a swap would read as the SAME +// generation, letting a stale index vouch for a spelling never proven on the +// filesystem now actually mounted there. Pinned at the generation seam +// (dirGeneration.equal) rather than a real bind mount, which CI cannot set +// up portably. +func TestDirGeneration_DeviceIsPartOfIdentity(t *testing.T) { + shared := dirGeneration{modTime: time.Unix(1, 0), changeTime: time.Unix(1, 0), size: 4096, ino: 42} + onDeviceA := shared + onDeviceA.dev = 1 + onDeviceB := shared + onDeviceB.dev = 2 + + assert.False(t, onDeviceA.equal(onDeviceB), + "the same inode/size/timestamps on a different device must not compare equal") + assert.True(t, onDeviceA.equal(onDeviceA), "a generation always equals itself") +} + +// TestDirGenerationOf_ReadsTheDevice pins that the path-based platform +// reader actually populates dev from a real Lstat, not just that equal() +// considers it. TestDirFdGeneration_ReadsTheDevice below pins the same for +// the fd-based reader round 11 introduced. +func TestDirGenerationOf_ReadsTheDevice(t *testing.T) { + dir := t.TempDir() + info, err := lstat(dir) + require.NoError(t, err) + gen := dirGenerationOf(info) + assert.NotZero(t, gen.dev, "a real directory's device must be read, not left at the zero value") +} + +// TestDirFdGeneration_ReadsTheDevice (round 11 MUST-FIX): the fd-based +// generation reader every scoped request and rebuild actually uses +// (dirfd_other.go) must populate dev/ino identically to the path-based +// reader the tests and the administrator's bookkeeping use — both describe +// the SAME real directory here, so they must agree exactly. +func TestDirFdGeneration_ReadsTheDevice(t *testing.T) { + dir := t.TempDir() + info, err := lstat(dir) + require.NoError(t, err) + fromPath := dirGenerationOf(info) + + fd, err := defaultOpenScopedDir(dir) + require.NoError(t, err) + defer func() { _ = unix.Close(fd) }() + fromFd, err := defaultFstatDirGeneration(fd) + require.NoError(t, err) + + assert.NotZero(t, fromFd.dev) + assert.Equal(t, fromPath.dev, fromFd.dev, "the same real directory's device must read the same whether reached by path or by descriptor") + assert.Equal(t, fromPath.ino, fromFd.ino) +} + +// TestStoredNamesFor_IdentityMismatchIsAMiss (round 11 MUST-FIX): even when +// a request's own directory descriptor happens to agree with the index's +// recorded generation on every OTHER field, a different device (the +// bind-mount-swap scenario round 9's dirGeneration.equal already refuses at +// the field-comparison level) must never authorize a hit, exercised here +// through the actual lookup function every request calls rather than only +// at the struct-equality level. +func TestStoredNamesFor_IdentityMismatchIsAMiss(t *testing.T) { + held := holdIndexRebuilds(t) + key := "codescripts-test-identity-mismatch-dir-does-not-exist" + forgetIndex(key) + t.Cleanup(func() { forgetIndex(key) }) + + idx := storedNamesIndex(key) + idx.mu.Lock() + idx.names = map[string]struct{}{"report.js": {}} + idx.gen = dirGeneration{modTime: time.Unix(1, 0), changeTime: time.Unix(1, 0), size: 4096, ino: 42, dev: 1} + idx.settled = true + idx.mu.Unlock() + + sameButDifferentDevice := dirGeneration{modTime: time.Unix(1, 0), changeTime: time.Unix(1, 0), size: 4096, ino: 42, dev: 2} + names, err := storedNamesFor(key, -1, sameButDifferentDevice) + require.NoError(t, err) + assert.Nil(t, names, "an index built for one directory must never answer for a request whose descriptor resolved to a different one") + held.land() // the mismatch schedules a (harmless, doomed-to-fail) rebuild of the bogus key; land it so nothing is left in flight +} + +// TestResolveScoped_DirectoryPathABA (round 11 MUST-FIX, the directory-path +// ABA hole): every earlier round's scoped resolution re-resolved scriptsDir +// BY PATH at each step — reading the generation, probing a candidate, +// opening it, and rechecking the generation were four independent lookups +// of the same path, each of which a replaceable symlink, ancestor +// directory, or bind mount retargeted between two of them could answer +// differently. The fix binds the whole request to the ONE descriptor +// storedSpellingsOf opens: everything the returned closures still do — the +// candidate probe already ran before the retarget below, the open, and the +// post-open recheck — must be UNAFFECTED by retargeting the path after that +// call returns, because none of them ever resolve scriptsDir again. A real +// symlink retarget between the session's own open and the caller's +// subsequent calls to open()/verifyUnchanged() is exactly the window a +// naive (path-re-resolving) implementation would lose to, and exactly the +// window production code — resolve(), in codescripts.go — leaves between +// calling candidates() and later calling the open and verify closures it +// returned. +func TestResolveScoped_DirectoryPathABA(t *testing.T) { + base := t.TempDir() + dirA := filepath.Join(base, "a") + dirB := filepath.Join(base, "b") + require.NoError(t, os.Mkdir(dirA, 0o755)) + require.NoError(t, os.Mkdir(dirB, 0o755)) + writeScript(t, dirA, "report.js", "FROM-A") + writeScript(t, dirB, "report.js", "FROM-B") + + link := filepath.Join(base, "scripts") + require.NoError(t, os.Symlink(dirA, link)) + warmStoredNames(t, link) + + storedExactly, open, verifyUnchanged, closeSession, err := storedSpellingsOf(link) + require.NoError(t, err) + require.NotNil(t, open, "Linux/BSD always binds the open to the retained descriptor (round 11 MUST-FIX)") + require.NotNil(t, verifyUnchanged) + if closeSession != nil { + defer closeSession() + } + + ok, err := storedExactly("report.js") + require.NoError(t, err) + require.True(t, ok) + + // The window the fix closes: retarget the symlink AFTER the session + // above already resolved it (dirA), before this request's remaining + // steps run — exactly the gap between resolve() calling candidates() + // and resolve() later calling the open and verify closures it got back. + require.NoError(t, os.Remove(link)) + require.NoError(t, os.Symlink(dirB, link)) + + f, err := open(filepath.Join(link, "report.js")) + require.NoError(t, err, "the open must succeed against the descriptor this request originally resolved") + defer f.Close() + data, err := io.ReadAll(f) + require.NoError(t, err) + assert.Equal(t, "FROM-A", string(data), "the open must read the directory this request originally resolved, never the retargeted one") + + assert.NoError(t, verifyUnchanged(f, "report.js"), "the retarget must not be visible to the post-open recheck either: it reads the SAME descriptor's generation, unaffected by what the path now points at") +} + +// TestStoredNames_EvictionCancelsAnInFlightRebuild (round 11 SHOULD, +// cancellable rebuilds): a rebuild goroutine still mid-listing when its +// index is evicted (LRU) or pruned (Warm keeping only the active directory) +// must stop promptly rather than keep listing for a directory nobody will +// query through it any longer, and must install NOTHING — there is no +// reader left it could still be wrong for. The goroutine is parked inside +// listScopedDirOnce (via the openScopedDir seam) so the eviction genuinely +// races an in-flight rebuild rather than one that already finished; wg is +// the seam that proves the goroutine actually stopped, not merely that +// cancel was called. +func TestStoredNames_EvictionCancelsAnInFlightRebuild(t *testing.T) { + quiesceIndexRebuilds() + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + + release := make(chan struct{}) + entered := make(chan struct{}, 1) + origOpen := openScopedDir + t.Cleanup(func() { openScopedDir = origOpen }) + openScopedDir = func(path string) (int, error) { + select { + case entered <- struct{}{}: + default: + } + <-release + return origOpen(path) + } + + key := filepath.Clean(dir) + idx := storedNamesIndex(key) + idx.mu.Lock() + idx.beginRebuildLocked() + idx.mu.Unlock() + idx.wg.Add(1) + go idx.rebuild(key, true, true) + + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("the rebuild goroutine never reached the directory-open seam") + } + + // Evict it exactly as LRU eviction / Warm's own pruning of every other + // directory would: cancel, then drop from the map. + forgetIndex(key) + + close(release) // let the blocked open proceed; the listing itself succeeds + + done := make(chan struct{}) + go func() { idx.wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("the rebuild goroutine did not stop after its index was evicted") + } + + idx.mu.Lock() + names, buildErr, building := idx.names, idx.err, idx.building + idx.mu.Unlock() + assert.Nil(t, names, "a cancelled rebuild installs nothing") + assert.NoError(t, buildErr, "nor does it install a failure") + assert.False(t, building, "the single-flight slot is released so a future request can rebuild") +} + +// TestStoredNames_WarmKeepsOnlyTheActiveDirectory (round 9 SHOULD): the +// server calls Warm whenever the active scripts directory changes, so Warm +// itself is where "only the active directory is warm" can be enforced — +// switching the active config path N times must leave exactly one index, +// not one per directory the process has ever served. +func TestStoredNames_WarmKeepsOnlyTheActiveDirectory(t *testing.T) { + quiesceIndexRebuilds() + settleStoredNamesClock(t) + + const n = 5 + dirs := make([]string, n) + for i := range dirs { + dirs[i] = t.TempDir() + writeScript(t, dirs[i], "alpha.js", "1") + } + + for _, d := range dirs { + require.NoError(t, Warm(d)) + } + + storedIndexesMu.Lock() + count := len(storedIndexes) + _, activeIsWarm := storedIndexes[filepath.Clean(dirs[n-1])] + storedIndexesMu.Unlock() + + assert.Equal(t, 1, count, "switching the active config path %d times must leave one index, not %d", n, n) + assert.True(t, activeIsWarm, "the index left behind must be the one Warm was last called for") + + // The still-active directory keeps answering; the abandoned ones are + // simply cold again (fail-closed until something warms or requests them + // afresh) rather than lost or corrupted. + src, _, err := ResolveScoped(dirs[n-1], "alpha", "") + require.NoError(t, err) + assert.Equal(t, "1", string(src)) +} + +// TestStoredNames_BareUseCapsAtLeastRecentlyUsed (round 9 SHOULD): a caller +// that never calls Warm (a scoped request against a directory the server +// never warmed) still must not grow storedIndexes without bound — the map +// caps at maxStoredNameIndexes, evicting the least-recently-used directory. +func TestStoredNames_BareUseCapsAtLeastRecentlyUsed(t *testing.T) { + quiesceIndexRebuilds() + storedIndexesMu.Lock() + storedIndexes = map[string]*storedNames{} + storedIndexesLRU = nil + storedIndexesMu.Unlock() + + keys := make([]string, maxStoredNameIndexes+3) + for i := range keys { + keys[i] = fmt.Sprintf("bare-use-dir-%d", i) + storedNamesIndex(keys[i]) + } + + storedIndexesMu.Lock() + count := len(storedIndexes) + _, oldestSurvived := storedIndexes[keys[0]] + _, newestSurvived := storedIndexes[keys[len(keys)-1]] + storedIndexesMu.Unlock() + + assert.Equal(t, maxStoredNameIndexes, count, "bare use is capped at maxStoredNameIndexes") + assert.False(t, oldestSurvived, "the least-recently-used directory is evicted first") + assert.True(t, newestSurvived, "the most recently touched directory survives") +} diff --git a/internal/codescripts/storednames_windows.go b/internal/codescripts/storednames_windows.go new file mode 100644 index 000000000..ca037c56d --- /dev/null +++ b/internal/codescripts/storednames_windows.go @@ -0,0 +1,614 @@ +//go:build windows + +package codescripts + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" + "sync" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +// Round 13 MUST-FIX (round-10 findings 2, 3 and 4 — unify Windows onto the +// same design every unix platform uses, darwin included as of this round): +// earlier rounds answered a scoped request from a per-path FindFirstFile +// probe (storedExactly: Lstat then, only on a hit, FindFirstFile) and opened +// the winning candidate by a FRESH, independent path lookup +// (openScriptFile) — two problems the maintainer's decision closes at once +// by giving Windows the same two things Linux/BSD/darwin have: +// +// 1. An exact-spelling INDEX (round-10 finding 3, the timing oracle): the +// directory is listed off the request path (Warm, and a single-flight +// rebuild goroutine when a request finds the index behind the +// directory's own generation), and a request answers from that index's +// map — an O(1) lookup that costs the SAME whether the name is absent or +// a differently cased variant exists, unlike Lstat-then-FindFirstFile's +// 0-vs-2-call asymmetry. +// +// 2. A directory HANDLE retained for the whole request (round-10 findings 2 +// and 4, the reparse-point/ancestor escape): winOpenScopedDir opens +// scriptsDir exactly ONCE; the candidate probe (winProbeEntry) and the +// actual open (winOpenEntry) are both performed RELATIVE TO THAT HANDLE +// via windows.NtCreateFile with OBJECT_ATTRIBUTES.RootDirectory set to +// it and ObjectName the bare basename — never a fresh path lookup that a +// retargeted reparse point on the directory itself or an ancestor could +// redirect. FILE_OPEN_REPARSE_POINT is the Windows analogue of O_NOFOLLOW +// (opens the reparse point itself rather than following it), and +// FILE_NON_DIRECTORY_FILE refuses a directory outright, atomically — no +// check-then-open window exists for either to land in. The share mode +// (FILE_SHARE_READ|WRITE|DELETE, finding 4) matches what open_windows.go +// now also requests for the administrator's own read: a script being +// read here can still be atomically replaced by a concurrent deploy. +// +// The post-open proof (verifyUnchanged) mirrors storednames_other.go's +// gen-before/gen-after recheck on the SAME retained handle — proving the +// directory itself was not swapped mid-request — plus, belt-and-suspenders, +// winOpenedBaseName (GetFinalPathNameByHandle on the OPENED file's own +// handle, basename only: the parent is already structurally bound by the +// relative open itself, so — unlike round 11's design, which had to compare +// the full path because opens were not yet handle-relative — only the +// basename is worth re-checking here). +// +// The index's own generation (winDirGeneration) folds the directory's +// IDENTITY — VolumeSerialNumber plus FileIndexHigh/Low, GetFileInformationByHandle, +// Windows's rough counterpart to a Unix dev+ino pair — together with its +// LastWriteTime, exactly as dirGeneration folds dev+ino with mtime/ctime on +// unix: a request whose retained handle resolves to a DIFFERENT directory +// than the one the index was built from (identity mismatch) is answered +// exactly like a stale generation — a plain miss, rebuild scheduled. The +// settle window (generationSettleTime, indexclock.go — shared with unix) is +// unchanged: NTFS timestamps are fine-grained, but a scripts directory is +// not guaranteed to live on an NTFS volume, and a FAT-formatted one carries +// the identical two-second write-time coarseness vfat has on Linux. + +// winDirGeneration is Windows's counterpart to dirGeneration +// (storednames_other.go). +type winDirGeneration struct { + volumeSerial uint32 + fileIndexHigh, fileIndexLow uint32 + lastWrite time.Time +} + +func (g winDirGeneration) equal(o winDirGeneration) bool { + return g.volumeSerial == o.volumeSerial && + g.fileIndexHigh == o.fileIndexHigh && g.fileIndexLow == o.fileIndexLow && + g.lastWrite.Equal(o.lastWrite) +} + +func (g winDirGeneration) latest() time.Time { return g.lastWrite } + +// winStoredNames is storedNames's (storednames_other.go) Windows +// counterpart — see there for the full rationale behind every field; this +// struct is built from a retained directory HANDLE rather than a file +// descriptor. +type winStoredNames struct { + mu sync.Mutex + names map[string]struct{} + err error + gen winDirGeneration + settled bool + + building bool + landed chan struct{} + + refreshAfter time.Time + nextAttempt time.Time + + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup +} + +// winStoredIndexes holds one *winStoredNames per cleaned scripts directory — +// the Windows counterpart of storedIndexes (storednames_other.go); see there +// for the LRU/pruning rationale, identical here. +var ( + winStoredIndexesMu sync.Mutex + winStoredIndexes = map[string]*winStoredNames{} + winStoredIndexesLRU []string +) + +func winStoredNamesIndex(key string) *winStoredNames { + winStoredIndexesMu.Lock() + defer winStoredIndexesMu.Unlock() + idx, ok := winStoredIndexes[key] + if !ok { + ctx, cancel := context.WithCancel(context.Background()) + idx = &winStoredNames{ctx: ctx, cancel: cancel} + winStoredIndexes[key] = idx + } + winTouchIndexLocked(key) + winEvictExcessLocked() + return idx +} + +func winTouchIndexLocked(key string) { + for i, k := range winStoredIndexesLRU { + if k == key { + winStoredIndexesLRU = append(winStoredIndexesLRU[:i], winStoredIndexesLRU[i+1:]...) + break + } + } + winStoredIndexesLRU = append(winStoredIndexesLRU, key) +} + +func winEvictExcessLocked() { + for len(winStoredIndexesLRU) > maxStoredNameIndexes { + oldest := winStoredIndexesLRU[0] + winStoredIndexesLRU = winStoredIndexesLRU[1:] + if idx, ok := winStoredIndexes[oldest]; ok { + idx.cancel() + } + delete(winStoredIndexes, oldest) + } +} + +func winPruneOtherIndexesLocked(keep string) { + for k, idx := range winStoredIndexes { + if k != keep { + idx.cancel() + delete(winStoredIndexes, k) + } + } + kept := winStoredIndexesLRU[:0] + for _, k := range winStoredIndexesLRU { + if k == keep { + kept = append(kept, k) + } + } + winStoredIndexesLRU = kept +} + +// winForgetIndex removes one directory's index entirely; test-only (mirrors +// forgetIndex, storednames_other.go). +func winForgetIndex(key string) { + winStoredIndexesMu.Lock() + defer winStoredIndexesMu.Unlock() + if idx, ok := winStoredIndexes[key]; ok { + idx.cancel() + } + delete(winStoredIndexes, key) + for i, k := range winStoredIndexesLRU { + if k == key { + winStoredIndexesLRU = append(winStoredIndexesLRU[:i], winStoredIndexesLRU[i+1:]...) + break + } + } +} + +// winForEachIndex calls fn for every currently held index; test-only +// (mirrors forEachIndex, storednames_other.go). +func winForEachIndex(fn func(*winStoredNames)) { + winStoredIndexesMu.Lock() + idxs := make([]*winStoredNames, 0, len(winStoredIndexes)) + for _, idx := range winStoredIndexes { + idxs = append(idxs, idx) + } + winStoredIndexesMu.Unlock() + for _, idx := range idxs { + fn(idx) + } +} + +// Warm builds the stored-name index of scriptsDir on the caller's goroutine +// — the Windows counterpart of Warm (storednames_other.go); see there for +// the full rationale, identical here down to Warm's own rebuild never being +// cancelled by pruneOtherIndexesLocked's cancellation of every OTHER +// directory's index. +func Warm(scriptsDir string) error { + key := filepath.Clean(scriptsDir) + idx := winStoredNamesIndex(key) + winStoredIndexesMu.Lock() + winPruneOtherIndexesLocked(key) + winStoredIndexesMu.Unlock() + for { + idx.mu.Lock() + if !idx.building { + idx.beginRebuildLocked() + idx.mu.Unlock() + break + } + landed := idx.landed + idx.mu.Unlock() + <-landed + } + // Round 17 SHOULD: same process-wide rebuildSlots bound as the unix + // Warm (storednames_other.go) — see there for the full rationale. + // Acquired here, OUTSIDE idx.mu, already released by the loop above. + rebuildSlots <- struct{}{} + defer func() { <-rebuildSlots }() + idx.wg.Add(1) + idx.rebuild(key, false, false) + idx.mu.Lock() + defer idx.mu.Unlock() + return idx.err +} + +// storedSpellingsOf answers, for one scoped request, whether scriptsDir +// holds an entry spelled exactly `want`, bound to the SINGLE directory +// handle this call opens — see this file's package doc comment above and +// storednames_other.go's identical unix contract. +func storedSpellingsOf(scriptsDir string) (storedExactly func(want string) (bool, error), open func(path string) (*os.File, error), verifyUnchanged func(f *os.File, want string) error, closeSession func(), err error) { + key := filepath.Clean(scriptsDir) + + dirHandle, err := winOpenScopedDir(key) + if err != nil { + return nil, nil, nil, nil, err + } + closeSession = func() { _ = windows.CloseHandle(dirHandle) } + + gen, err := winFstatDirGeneration(dirHandle) + if err != nil { + closeSession() + return nil, nil, nil, nil, err + } + + names, lookupErr := winStoredNamesFor(key, gen) + if lookupErr != nil { + closeSession() + return nil, nil, nil, nil, lookupErr + } + + storedExactly = func(want string) (bool, error) { + if _, ok := names[want]; !ok { + return false, nil + } + if err := winProbeEntry(dirHandle, want); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, err + } + return true, nil + } + open = func(path string) (*os.File, error) { + return winOpenEntry(dirHandle, filepath.Base(path)) + } + verifyUnchanged = func(f *os.File, want string) error { + cur, err := winFstatDirGeneration(dirHandle) + if err != nil { + return err + } + if !cur.equal(gen) { + return errIndexGenerationChanged + } + // The parent is already bound by the relative open itself + // (winOpenEntry, via RootDirectory) — only the basename is worth + // re-checking here, unlike round 11's full-path comparison, which + // existed only because that round's open was still a fresh, unbound + // path lookup. + got, err := winOpenedBaseName(f) + if err != nil || got != want { + return errSpellingUnproven + } + return nil + } + return storedExactly, open, verifyUnchanged, closeSession, nil +} + +// winStoredNamesFor is storedNamesFor's (storednames_other.go) Windows +// counterpart: identical contract, one directory handle's freshly read +// generation in, the index's names (or nil, fail-closed) out. +func winStoredNamesFor(key string, gen winDirGeneration) (names map[string]struct{}, err error) { + idx := winStoredNamesIndex(key) + now := indexClock() + + idx.mu.Lock() + defer idx.mu.Unlock() + + current := (idx.names != nil || idx.err != nil) && idx.gen.equal(gen) + + switch { + case !current: + idx.scheduleRebuildLocked(key, now) + case !idx.settled && !now.Before(idx.refreshAfter): + idx.scheduleRebuildLocked(key, now) + } + + if !current || !idx.settled { + return nil, nil + } + return idx.names, idx.err +} + +// beginRebuildLocked claims the single-flight slot. +func (idx *winStoredNames) beginRebuildLocked() { + idx.building = true + idx.landed = make(chan struct{}) +} + +// scheduleRebuildLocked mirrors storedNames.scheduleRebuildLocked +// (storednames_other.go) exactly, rebuildSlots (round 13 SHOULD, finding 5) +// included: the semaphore is process-wide and shared with the unix index +// implementation (rebuildsemaphore.go), since the two never build together. +func (idx *winStoredNames) scheduleRebuildLocked(key string, now time.Time) { + idx.refreshAfter = now.Add(generationSettleTime) + if idx.building { + return + } + if !idx.nextAttempt.IsZero() && now.Before(idx.nextAttempt) { + return + } + select { + case rebuildSlots <- struct{}{}: + default: + return + } + idx.beginRebuildLocked() + idx.wg.Add(1) + spawnIndexRebuild(func() { + defer func() { <-rebuildSlots }() + idx.rebuild(key, true, true) + }) +} + +// rebuild mirrors storedNames.rebuild (storednames_other.go) exactly. +func (idx *winStoredNames) rebuild(key string, backoffAfter, cancellable bool) { + defer idx.wg.Done() + for attempt := 1; ; attempt++ { + if cancellable && idx.ctx.Err() != nil { + idx.finishRebuild(backoffAfter) + return + } + before, after, names, listErr := winListScopedDirOnce(key) + now := indexClock() + if cancellable && idx.ctx.Err() != nil { + idx.finishRebuild(backoffAfter) + return + } + if listErr == nil && attempt < maxRebuildAttempts && !before.equal(after) { + continue + } + gen := after + if listErr != nil { + gen = before + } + idx.mu.Lock() + idx.names, idx.err, idx.gen = names, listErr, gen + idx.settled = listErr == nil && now.Sub(gen.latest()) >= generationSettleTime + idx.building = false + if backoffAfter { + idx.nextAttempt = indexClock().Add(rebuildBackoff) + } + close(idx.landed) + idx.mu.Unlock() + return + } +} + +// finishRebuild mirrors storedNames.finishRebuild (storednames_other.go). +func (idx *winStoredNames) finishRebuild(backoffAfter bool) { + idx.mu.Lock() + idx.building = false + if backoffAfter { + idx.nextAttempt = indexClock().Add(rebuildBackoff) + } + close(idx.landed) + idx.mu.Unlock() +} + +// winListScopedDirOnce mirrors defaultListScopedDirOnce (storednames_other.go): +// one directory handle serves the generation read AND the listing, so a +// change during the listing is caught by the before/after reads disagreeing +// without ever resolving the path a second time. A variable so the tests +// can inject the directory-open seam's behaviour directly. +var winListScopedDirOnce = defaultWinListScopedDirOnce + +func defaultWinListScopedDirOnce(key string) (before, after winDirGeneration, names map[string]struct{}, err error) { + h, err := winOpenScopedDir(key) + if err != nil { + return winDirGeneration{}, winDirGeneration{}, nil, err + } + defer func() { _ = windows.CloseHandle(h) }() + + before, err = winFstatDirGeneration(h) + if err != nil { + return winDirGeneration{}, winDirGeneration{}, nil, err + } + entryNames, err := winListScopedDir(h, key) + if err != nil { + return before, winDirGeneration{}, nil, err + } + after, err = winFstatDirGeneration(h) + if err != nil { + return before, winDirGeneration{}, nil, err + } + names = make(map[string]struct{}, len(entryNames)) + for _, n := range entryNames { + names[n] = struct{}{} + } + return before, after, names, nil +} + +// The primitives below are variables, exactly as dirfd_other.go's are, so +// the package's tests can hook them individually. +var ( + winOpenScopedDir = defaultWinOpenScopedDir + winFstatDirGeneration = defaultWinFstatDirGeneration + winProbeEntry = defaultWinProbeEntry + winOpenEntry = defaultWinOpenEntry + winListScopedDir = defaultWinListScopedDir +) + +// defaultWinOpenScopedDir opens scriptsDir once. FILE_FLAG_BACKUP_SEMANTICS +// is required to obtain any handle on a directory at all; the share mode +// (round 13 MUST-FIX, finding 4) matches open_windows.go's own +// openScriptFile — READ|WRITE|DELETE — so holding this handle for the +// request's duration cannot itself block a concurrent write or atomic +// replace anywhere under the directory. +// +// Round 13 sibling sweep: unlike unix's O_DIRECTORY (dirfd_other.go), +// CreateFile with FILE_FLAG_BACKUP_SEMANTICS does not itself refuse a path +// that now names a plain FILE — the scripts directory replaced by a file is +// exactly the sibling class this round's sweep calls out — so the type is +// checked explicitly here, on the SAME handle everything else in the +// request is bound to, and refused (unreadable) rather than silently +// treating an ordinary file as an empty directory. +func defaultWinOpenScopedDir(path string) (windows.Handle, error) { + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + h, err := windows.CreateFile(p, + windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, + windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS, + 0) + if err != nil { + return 0, &os.PathError{Op: "open", Path: path, Err: err} + } + var fi windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(h, &fi); err != nil { + _ = windows.CloseHandle(h) + return 0, &os.PathError{Op: "open", Path: path, Err: err} + } + if fi.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0 { + _ = windows.CloseHandle(h) + return 0, &os.PathError{Op: "open", Path: path, Err: windows.ERROR_DIRECTORY} + } + return h, nil +} + +// defaultWinFstatDirGeneration reads a directory's generation from an +// already-open handle — GetFileInformationByHandle, never a path lookup — +// so it can be called again, after the candidate open, without re-resolving +// scriptsDir. VolumeSerialNumber + FileIndexHigh/Low is the directory's +// IDENTITY (round 13, the maintainer's exact instruction — Windows's +// counterpart to a Unix dev+ino pair); LastWriteTime is what moves whenever +// the directory's entry set changes. +func defaultWinFstatDirGeneration(h windows.Handle) (winDirGeneration, error) { + var fi windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(h, &fi); err != nil { + return winDirGeneration{}, err + } + return winDirGeneration{ + volumeSerial: fi.VolumeSerialNumber, + fileIndexHigh: fi.FileIndexHigh, + fileIndexLow: fi.FileIndexLow, + lastWrite: time.Unix(0, fi.LastWriteTime.Nanoseconds()), + }, nil +} + +// defaultWinListScopedDir lists h's entries through a DUPLICATE of the +// handle: os.File.Close on the duplicate releases only the copy, leaving +// the caller's own h untouched. DuplicateHandle, not a fresh CreateFile, +// so the listing is bound to the identical open the generation came from — +// standard library os.File.Readdirnames on Windows lists via the handle +// itself (GetFileInformationByHandleEx), never by re-resolving the path +// string os.NewFile is given for bookkeeping. +func defaultWinListScopedDir(h windows.Handle, path string) ([]string, error) { + cur := windows.CurrentProcess() + var dup windows.Handle + if err := windows.DuplicateHandle(cur, h, cur, &dup, 0, false, windows.DUPLICATE_SAME_ACCESS); err != nil { + return nil, err + } + f := os.NewFile(uintptr(dup), path) + defer func() { _ = f.Close() }() + return f.Readdirnames(-1) +} + +// defaultWinProbeEntry probes name relative to dirHandle — the candidate's +// own existence check, bound to the SAME handle the generation was just +// read from rather than a fresh path lookup (round 13 MUST-FIX, finding 2: +// exactly the second, independent path resolution that let a retargeted +// reparse point substitute a different file). Minimal access +// (FILE_READ_ATTRIBUTES) and FILE_OPEN_REPARSE_POINT: this is existence +// only, mirroring fstatatEntry (dirfd_other.go) — it does not itself decide +// regular-vs-not; the actual open plus resolve's own f.Stat() does that. +func defaultWinProbeEntry(dirHandle windows.Handle, name string) error { + h, err := ntCreateRelative(dirHandle, name, + windows.FILE_READ_ATTRIBUTES|windows.SYNCHRONIZE, + windows.FILE_OPEN_REPARSE_POINT|windows.FILE_SYNCHRONOUS_IO_NONALERT) + if err != nil { + return &os.PathError{Op: "open", Path: name, Err: err} + } + _ = windows.CloseHandle(h) + return nil +} + +// defaultWinOpenEntry opens name relative to dirHandle — the entry +// winProbeEntry already probed, opened relative to the SAME handle, never a +// second, independent lookup of the name (round 13 MUST-FIX, finding 2). +// FILE_OPEN_REPARSE_POINT is the Windows analogue of O_NOFOLLOW (opens the +// reparse point itself, atomically, rather than transparently resolving it +// — the same no-check-then-open-window guarantee open_windows.go's own +// openScriptFile relies on); FILE_NON_DIRECTORY_FILE refuses a directory at +// the NtCreateFile layer itself. GetFileInformationByHandle on the opened +// handle then refuses a reparse point or (belt-and-suspenders) a directory, +// exactly as open_windows.go's openScriptFile does for the administrator. +func defaultWinOpenEntry(dirHandle windows.Handle, name string) (*os.File, error) { + h, err := ntCreateRelative(dirHandle, name, + windows.FILE_GENERIC_READ, + windows.FILE_OPEN_REPARSE_POINT|windows.FILE_NON_DIRECTORY_FILE|windows.FILE_SYNCHRONOUS_IO_NONALERT) + if err != nil { + return nil, &os.PathError{Op: "open", Path: name, Err: err} + } + var fi windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(h, &fi); err != nil { + _ = windows.CloseHandle(h) + return nil, err + } + if fi.FileAttributes&(windows.FILE_ATTRIBUTE_REPARSE_POINT|windows.FILE_ATTRIBUTE_DIRECTORY) != 0 { + _ = windows.CloseHandle(h) + return nil, errNonRegular + } + return os.NewFile(uintptr(h), name), nil +} + +// winOpenedBaseName is this file's counterpart to darwin's F_GETPATH +// belt-and-suspenders proof (entryname_darwin.go): the on-disk basename +// GetFinalPathNameByHandle reports for the OPENED descriptor, not a +// separate pre-open probe of the same name. +func winOpenedBaseName(f *os.File) (string, error) { + return openedBaseName(f) +} + +// ntCreateRelative opens name relative to dirHandle via windows.NtCreateFile +// — OBJECT_ATTRIBUTES.RootDirectory bound to dirHandle, ObjectName the bare +// basename — so the lookup can never traverse outside dirHandle's own +// directory: a rename of the directory itself, or a reparse point planted +// on an ancestor, cannot redirect a RELATIVE open the way it could a fresh +// path lookup (round 13 MUST-FIX, finding 2). Share mode +// READ|WRITE|DELETE matches open_windows.go's own openScriptFile (round 13 +// MUST-FIX, finding 4). +func ntCreateRelative(dirHandle windows.Handle, name string, access, options uint32) (windows.Handle, error) { + objName, err := windows.NewNTUnicodeString(name) + if err != nil { + return 0, err + } + oa := &windows.OBJECT_ATTRIBUTES{ + Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})), + RootDirectory: dirHandle, + ObjectName: objName, + Attributes: windows.OBJ_CASE_INSENSITIVE, + } + var h windows.Handle + var iosb windows.IO_STATUS_BLOCK + ntErr := windows.NtCreateFile(&h, access, oa, &iosb, nil, + windows.FILE_ATTRIBUTE_NORMAL, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + windows.FILE_OPEN, + options, + 0, 0) + if ntErr != nil { + if st, ok := ntErr.(windows.NTStatus); ok { + // FILE_NON_DIRECTORY_FILE refuses a directory at the kernel: + // that is the non-regular answer the Unix Fstat check gives, + // not an unreadable entry. + if st == windows.STATUS_FILE_IS_A_DIRECTORY { + return 0, errNonRegular + } + return 0, st.Errno() + } + return 0, ntErr + } + return h, nil +} diff --git a/internal/codescripts/storedspellings_probe_test.go b/internal/codescripts/storedspellings_probe_test.go new file mode 100644 index 000000000..77208e8b5 --- /dev/null +++ b/internal/codescripts/storedspellings_probe_test.go @@ -0,0 +1,162 @@ +//go:build windows + +package codescripts + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// Round 13 (round-10 findings 2, 3 and 4): Windows joined the same +// per-directory exact-spelling INDEX every unix platform uses +// (storednames_windows.go) instead of a per-request FindFirstFile probe, so +// these helpers now mirror storednames_other_test.go's (Linux/BSD/darwin) +// rather than being no-ops — there is real off-request-path work to +// quiesce and a real settle window to fake. + +// quiesceIndexRebuilds waits for every rebuild goroutine the tests so far +// have left in flight. +func quiesceIndexRebuilds() { + winForEachIndex(func(idx *winStoredNames) { + idx.mu.Lock() + building, landed := idx.building, idx.landed + idx.mu.Unlock() + if building { + <-landed + } + }) +} + +// settleStoredNamesClock moves the index clock far past any directory the +// test writes, so an index taken now counts as settled and is trusted until +// the directory's generation moves. Restored on cleanup. +func settleStoredNamesClock(t *testing.T) { + t.Helper() + quiesceIndexRebuilds() + orig := indexClock + indexClock = func() time.Time { return orig().Add(time.Hour) } + t.Cleanup(func() { + quiesceIndexRebuilds() + indexClock = orig + }) +} + +// warmStoredNames builds the stored-name index of dir once, with the clock +// settled, so the shared tests that count a scoped resolution's directory +// reads start from a warm index — as the server does at construction. +func warmStoredNames(t *testing.T, dir string) { + t.Helper() + settleStoredNamesClock(t) + require.NoError(t, Warm(dir)) +} + +// TestStoredSpellingsOf_PostOpenProofAcceptsAnUnchangedDescriptor is the +// positive control for the post-open proof: nothing raced the open, so the +// opened descriptor's own generation recheck and stored-basename proof both +// still agree with what was requested and probed. +func TestStoredSpellingsOf_PostOpenProofAcceptsAnUnchangedDescriptor(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + warmStoredNames(t, dir) + + storedExactly, open, verifyUnchanged, closeSession, err := storedSpellingsOf(dir) + require.NoError(t, err) + require.NotNil(t, open, "round 13: Windows opens the winning candidate relative to the retained directory handle, exactly as unix does") + require.NotNil(t, verifyUnchanged) + if closeSession != nil { + defer closeSession() + } + ok, err := storedExactly("alpha.js") + require.NoError(t, err) + require.True(t, ok) + + f, err := open(filepath.Join(dir, "alpha.js")) + require.NoError(t, err) + defer f.Close() + + assert.NoError(t, verifyUnchanged(f, "alpha.js")) +} + +// TestStoredSpellingsOf_PostOpenProofCatchesARaceOnTheOpenedDescriptor: +// the pre-open probe (storedExactly) is only a cheap gate — the directory's +// generation (winFstatDirGeneration on the SAME retained handle) is what +// the post-open recheck actually trusts. A rename that lands between the +// probe and the open moves the directory's own LastWriteTime, so the +// recheck must refuse even though the open itself, relative to the +// retained handle, still succeeds against whatever the winning candidate's +// name resolves to at that moment. +func TestStoredSpellingsOf_PostOpenProofCatchesARaceOnTheOpenedDescriptor(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "alpha.js", "1") + warmStoredNames(t, dir) + + _, open, verifyUnchanged, closeSession, err := storedSpellingsOf(dir) + require.NoError(t, err) + require.NotNil(t, open) + require.NotNil(t, verifyUnchanged) + if closeSession != nil { + defer closeSession() + } + + f, err := open(filepath.Join(dir, "alpha.js")) + require.NoError(t, err) + defer f.Close() + + // The race: another entry is written into the SAME directory between the + // open and the recheck, moving the directory's own generation — exactly + // the round-8 lookup→open race the shared generation recheck exists to + // catch, here exercised through the Windows primitives. NTFS is not + // guaranteed to flush a directory's LastWriteTime to a value distinct + // from what a handle opened moments earlier already observed (CI + // runners have been seen to coalesce the two within the same 100ns + // FILETIME tick) — force it forward explicitly, the same mitigation + // the unix counterpart (TestResolveScoped_GenerationChangeBetweenLookupAndOpenRefuses) + // uses, so the assertion is about the recheck logic, not filesystem + // timestamp granularity. + writeScript(t, dir, "beta.js", "2") + require.NoError(t, os.Chtimes(dir, time.Now(), time.Now().Add(time.Second))) + + verifyErr := verifyUnchanged(f, "alpha.js") + require.Error(t, verifyErr, "the directory's own generation moved between the open and the recheck") + assert.True(t, errors.Is(verifyErr, errIndexGenerationChanged)) +} + +// TestStoredSpellingsOf_DirectoryHandleIdentityMismatchIsAMiss (round 13, +// mirrors TestStoredNamesFor_IdentityMismatchIsAMiss and +// TestResolveScoped_DirectoryPathABA on unix): an index built from one +// directory must never authorize a request whose retained handle resolves +// to a DIFFERENT directory at the same path — the identity half of +// winDirGeneration (VolumeSerialNumber + FileIndexHigh/Low) is what this +// pins, directly at the winStoredNamesFor seam rather than through a real +// directory replacement (not reliably reproducible without an elevated +// symlink/junction on every CI runner). +func TestStoredSpellingsOf_DirectoryHandleIdentityMismatchIsAMiss(t *testing.T) { + dirA := t.TempDir() + writeScript(t, dirA, "alpha.js", "1") + warmStoredNames(t, dirA) + + key := filepath.Clean(dirA) + h, err := winOpenScopedDir(key) + require.NoError(t, err) + defer func() { _ = windows.CloseHandle(h) }() + genA, err := winFstatDirGeneration(h) + require.NoError(t, err) + + // A generation that shares dirA's LastWriteTime but claims a different + // identity (as if the retained handle now resolved to a different + // directory occupying the same path) must be refused exactly like a + // stale generation — a miss, never an authorized hit. + spoofed := genA + spoofed.fileIndexLow++ + + names, err := winStoredNamesFor(key, spoofed) + require.NoError(t, err) + assert.Nil(t, names, "an identity mismatch is refused exactly like a never-built index") +} diff --git a/internal/httpapi/code_exec.go b/internal/httpapi/code_exec.go index 678019259..44b9dcc8b 100644 --- a/internal/httpapi/code_exec.go +++ b/internal/httpapi/code_exec.go @@ -182,11 +182,12 @@ func (h *CodeExecHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { result, err := h.toolCaller.CallTool(ctx, "code_execution", args) if err != nil { // A refusal the caller could have avoided is not a server fault. Naming - // a script that does not exist is the documented discovery path, and a - // mistyped or ambiguous name is a caller mistake; answered as 500 they - // look retryable to an agent's retry policy and count as server errors - // in monitoring. The tool's own explanation is what travels, since that - // text is how the caller recovers. + // a script that does not exist is the administrator's documented + // discovery path (an agent token gets the non-disclosing form, Spec + // 105 FR-012), and a mistyped or ambiguous name is a caller mistake; + // answered as 500 they look retryable to an agent's retry policy and + // count as server errors in monitoring. The tool's own explanation is + // what travels, since that text is how the caller recovers. if status, code, message, ok := classifyCodeExecError(err); ok { h.logger.Debugw("Code execution refused", "status", status, "code", code, "error", err) h.writeError(w, r, status, code, message) @@ -229,7 +230,10 @@ func classifyCodeExecError(err error) (status int, code, message string, ok bool var notFound *codescripts.NotFoundError if errors.As(err, ¬Found) { // 404 rather than 400: the request is well formed, the script is not - // there — and the message carries the available names (FR-004). + // there. The message is the error's own: the available names for an + // administrator (FR-004), the non-disclosing text for an agent token + // (Spec 105 FR-012) — the same typed identity either way, which is why + // the status is decided here and the wording is not rebuilt. return http.StatusNotFound, "SCRIPT_NOT_FOUND", notFound.Error(), true } diff --git a/internal/httpapi/code_exec_status_test.go b/internal/httpapi/code_exec_status_test.go index 786a5b963..58c63dedd 100644 --- a/internal/httpapi/code_exec_status_test.go +++ b/internal/httpapi/code_exec_status_test.go @@ -46,11 +46,12 @@ func TestCodeExec_ScriptResolutionFailuresAreClientErrors(t *testing.T) { wrap := func(err error) error { return fmt.Errorf("tool call failed: %w", err) } tests := []struct { - name string - err error - wantStatus int - wantCode string - wantInMsg string + name string + err error + wantStatus int + wantCode string + wantInMsg string + wantNotInMsg []string }{ { name: "not found carries the discovery listing", @@ -64,6 +65,39 @@ func TestCodeExec_ScriptResolutionFailuresAreClientErrors(t *testing.T) { wantCode: "SCRIPT_NOT_FOUND", wantInMsg: "daily-report", }, + { + // Spec 105 FR-012: the scoped form keeps the typed identity — the + // same 404 / SCRIPT_NOT_FOUND — with its own non-disclosing text. + // The classifier must keep using .Error() rather than rebuilding + // the message from the (now empty) fields. + name: "not found, agent-token form, discloses nothing", + err: wrap((&codescripts.NotFoundError{ + Name: "nope", + Dir: "/cfg/scripts", + Available: []string{"daily-report"}, + Total: 1, + }).NonDisclosing()), + wantStatus: http.StatusNotFound, + wantCode: "SCRIPT_NOT_FOUND", + wantInMsg: "administrators only", + wantNotInMsg: []string{"daily-report", "/cfg/scripts", "(1)"}, + }, + { + name: "ambiguous, agent-token form, discloses no path", + err: wrap((&codescripts.AmbiguousError{Name: "dup", Paths: []string{"/cfg/scripts/dup.js", "/cfg/scripts/dup.ts"}}).NonDisclosing()), + wantStatus: http.StatusBadRequest, + wantCode: "SCRIPT_UNUSABLE", + wantInMsg: "ambiguous", + wantNotInMsg: []string{"/cfg/scripts"}, + }, + { + name: "unreadable, agent-token form, discloses no path or OS error", + err: wrap((&codescripts.InvalidError{Name: "x", Path: "/cfg/scripts", Reason: codescripts.ReasonUnreadable, Detail: "open /cfg/scripts: permission denied"}).NonDisclosing()), + wantStatus: http.StatusBadRequest, + wantCode: "SCRIPT_UNUSABLE", + wantInMsg: codescripts.ReasonUnreadable, + wantNotInMsg: []string{"/cfg/scripts", "permission denied"}, + }, { name: "invalid name", err: wrap(&codescripts.InvalidNameError{Name: "../etc/passwd", Reason: "character \"/\" is not allowed"}), @@ -105,6 +139,10 @@ func TestCodeExec_ScriptResolutionFailuresAreClientErrors(t *testing.T) { assert.Equal(t, tc.wantCode, decoded.Error.Code) assert.Contains(t, decoded.Error.Message, tc.wantInMsg, "the tool's own explanation must survive the status mapping — it is how a caller recovers") + for _, absent := range tc.wantNotInMsg { + assert.NotContains(t, decoded.Error.Message, absent, + "the REST surface must not re-disclose what the scoped form withheld (FR-012)") + } }) } diff --git a/internal/httpapi/code_scripts.go b/internal/httpapi/code_scripts.go index 8282eeaa2..d60e9df73 100644 --- a/internal/httpapi/code_scripts.go +++ b/internal/httpapi/code_scripts.go @@ -14,17 +14,33 @@ type CodeScriptsResponse struct { Dir string `json:"dir"` } +// scriptsListingDenialMessage is the body an agent token receives from the +// stored-script listing. It names nothing about the directory. +const scriptsListingDenialMessage = "Agent tokens cannot list stored scripts (the stored-script listing is available to administrators only)" + // handleListScripts godoc // @Summary List stored code-execution scripts -// @Description List the stored scripts available to the code_execution tool. Scripts are `.js` / `.ts` files in the `scripts/` directory next to the active configuration file. Entries are advisory: `ok` scripts are invocable, `ambiguous` names have both extensions, and `invalid` ones report why (empty, oversized, unreadable, non-regular). Read-only — there is no write surface for stored scripts. +// @Description List the stored scripts available to the code_execution tool. Scripts are `.js` / `.ts` files in the `scripts/` directory next to the active configuration file. Entries are advisory: `ok` scripts are invocable, `ambiguous` names have both extensions, and `invalid` ones report why (empty, oversized, unreadable, non-regular). Read-only — there is no write surface for stored scripts. Administrator-only (Spec 105 FR-012): an agent token, whatever its server scope, is refused with 403 — the listing is the enumeration the missing-script error withholds from a scoped caller. // @Tags code // @Produce json // @Security ApiKeyAuth // @Security ApiKeyQuery // @Success 200 {object} contracts.SuccessResponse "Stored scripts and the directory they were read from" +// @Failure 403 {object} contracts.ErrorResponse "Agent tokens cannot list stored scripts" // @Failure 500 {object} contracts.ErrorResponse "Internal server error" // @Router /api/v1/code/scripts [get] func (s *Server) handleListScripts(w http.ResponseWriter, r *http.Request) { + // Spec 105 FR-012: the listing — names, count, paths AND the directory — + // is exactly what the missing-script refusal withholds from a scoped + // caller, so it is administrator-only here too. requireAdminRead keys on + // the same predicate (!IsAdmin) the MCP refusal uses, so the two doors + // share one definition of "administrator": the admin API key, the tray + // over the socket and an absent context pass; every agent token is + // refused before the directory is read. + if !s.requireAdminRead(w, r, scriptsListingDenialMessage) { + return + } + // The scripts directory follows the ACTIVE config file, the same authority // the code_execution handler resolves against — a listing that disagreed // with what executes would be worse than no listing at all. diff --git a/internal/httpapi/code_scripts_test.go b/internal/httpapi/code_scripts_test.go index c20c4f54c..ac143463a 100644 --- a/internal/httpapi/code_scripts_test.go +++ b/internal/httpapi/code_scripts_test.go @@ -139,3 +139,45 @@ func TestHandleListScripts_RequiresAPIKey(t *testing.T) { recorder := getCodeScripts(t, srv, "") assert.Equal(t, http.StatusUnauthorized, recorder.Code, "body: %s", recorder.Body.String()) } + +// TestHandleListScripts_AgentTokenForbidden (Spec 105 FR-012, critique r1 #1): +// the listing is the enumeration the missing-script refusal withholds from a +// scoped caller, so it must be administrator-only on the REST surface too — +// otherwise `GET /api/v1/code/scripts` is the oracle a failed call no longer +// is. The unrestricted ["*"] token is the strongest cell: the caller KIND +// decides, never its server scope. The admin API key keeps the listing +// (SC-005); the socket/tray and nil-context callers share requireAdminRead's +// one definition of "not an administrator". +func TestHandleListScripts_AgentTokenForbidden(t *testing.T) { + const sentinel = "alpha-SENTINEL" + ctrl := &codeScriptsController{apiKey: "admin-secret", configPath: filepath.Join(t.TempDir(), "mcp_config.json")} + scriptsDir := codescripts.DirFor(ctrl.configPath) + require.NoError(t, os.MkdirAll(scriptsDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, sentinel+".js"), []byte("1"), 0o600)) + + srv, agentToken := agentTokenServer(t, ctrl) + + t.Run("agent token is refused without the listing", func(t *testing.T) { + recorder := getCodeScripts(t, srv, agentToken) + assert.Equal(t, http.StatusForbidden, recorder.Code, "body: %s", recorder.Body.String()) + body := recorder.Body.String() + assert.NotContains(t, body, sentinel, "an agent-token caller must not learn stored script names (FR-012)") + assert.NotContains(t, body, scriptsDir, "an agent-token caller must not learn the scripts directory (FR-012)") + }) + + t.Run("administrator control keeps the listing", func(t *testing.T) { + recorder := getCodeScripts(t, srv, ctrl.apiKey) + require.Equal(t, http.StatusOK, recorder.Code, "body: %s", recorder.Body.String()) + assert.Contains(t, recorder.Body.String(), sentinel) + // Compare the decoded field, not the raw body: on Windows the JSON + // encoder escapes the path's backslashes, so a raw substring match on + // the OS path fails there. + var listing struct { + Data struct { + Dir string `json:"dir"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &listing)) + assert.Equal(t, scriptsDir, listing.Data.Dir, "the administrator listing keeps the scripts directory") + }) +} diff --git a/internal/server/mcp.go b/internal/server/mcp.go index e55eaab1d..f560aa151 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -293,6 +293,10 @@ type MCPProxyServer struct { // Empty in constructions that did not declare one; see // activeConfigFilePath() for the fallback order. configFilePath string + + // warmedScriptsDir is the scripts directory whose stored-name index was + // last warmed (Spec 105 FR-012); scriptsDir re-warms when it moves. + warmedScriptsDir atomic.Pointer[string] } // MCPProxyOption customizes an MCPProxyServer at construction time. @@ -629,6 +633,12 @@ func NewMCPProxyServer( // Let the hooks (registered before the proxy existed) reach it. proxyRef.Store(proxy) + // Build the stored-script index now that the scripts directory is known, + // so no scoped request ever lists it (Spec 105 FR-012). + scriptsDir := proxy.scriptsDir() + proxy.warmedScriptsDir.Store(&scriptsDir) + proxy.warmStoredScripts(scriptsDir) + // Register proxy tools for the default (retrieve_tools) server proxy.registerTools(debugSearch) diff --git a/internal/server/mcp_code_execution.go b/internal/server/mcp_code_execution.go index d2683f681..d6ca30e49 100644 --- a/internal/server/mcp_code_execution.go +++ b/internal/server/mcp_code_execution.go @@ -50,8 +50,9 @@ const ( "**TypeScript support**: Set `language: \"typescript\"` to write TypeScript code with type annotations, interfaces, enums, and generics. " + "Types are automatically stripped before execution.\n\n" + "**Stored scripts**: Instead of `code`, pass `script: \"\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — " + - "a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. Naming a script that does not exist returns the " + - "available names, which is how you discover what is stored.\n\n" + + "a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. The stored-script listing is administrator-only " + + "(`mcpproxy code scripts list`, or the not-found error under the admin API key); an agent-token caller must already know the script name — " + + "a name that does not exist is refused without naming what is stored.\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" + @@ -71,8 +72,9 @@ const ( "directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. " + "Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. " + "The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. " + - "DISCOVERY: calling with a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total), " + - "so the current set can always be recovered from a single failed call. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code." + "ENUMERATION IS ADMINISTRATOR-ONLY: for an administrator (the admin API key, the tray, an in-process caller) a name that does not exist returns an error listing " + + "the available script names (first 20 alphabetically, plus the total); an agent-token caller must already know the script name — its not-found error " + + "names neither the stored scripts nor how many there are. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code." codeExecutionInputDescription = "Input data accessible as global `input` variable in code (default: {})" @@ -495,7 +497,7 @@ func (p *MCPProxyServer) resolveCodeExecutionSource(ctx context.Context, args ma return code, "", "" } - source, language, err := codescripts.Resolve(p.scriptsDir(), scriptName, options.Language) + source, language, err := p.resolveStoredScript(ctx, scriptName, options.Language) if err != nil { // Keep the typed identity reachable for the REST surface (404 for a // name that is not there, 400 for one that cannot run) — the text alone @@ -507,6 +509,34 @@ func (p *MCPProxyServer) resolveCodeExecutionSource(ctx context.Context, args ma return string(source), scriptName, "" } +// resolveStoredScript applies the Spec 105 FR-012 caller-kind rule to +// stored-script resolution. The Spec 097 FR-004 not-found error enumerates +// the stored names and their count so an administrator recovers the set from +// one failed call, and its sibling refusals (ambiguous, unusable, unreadable) +// name the host path they are about; for a scoped caller (an agent token, +// whatever its server scope — the caller KIND decides, never AllowedServers) +// the listing is never even computed and every refusal is the non-disclosing +// form (codescripts.ResolveScoped): the caller's own name and the reason, +// independent of the directory's contents and location, so a failed call is +// not an oracle for what is stored or where. An absent auth context +// (in-process caller) or an administrator — including the anonymous, +// admin-shaped /mcp caller under require_mcp_auth=false — keeps the +// enumeration (SC-005: the named FR-012 admin exception). +func (p *MCPProxyServer) resolveStoredScript(ctx context.Context, scriptName, explicitLanguage string) ([]byte, string, error) { + if !auth.IsScopedCaller(ctx) { + return codescripts.Resolve(p.scriptsDir(), scriptName, explicitLanguage) + } + source, language, err := codescripts.ResolveScoped(p.scriptsDir(), scriptName, explicitLanguage) + if err != nil { + // The refusal deliberately carries no count or path; the log line + // records only that a scoped probe was refused, for the same reason. + p.logger.Debug("Stored-script refusal delivered in non-disclosing form to scoped caller (Spec 105 FR-012)", + zap.String("script", scriptName), + zap.String("refusal", fmt.Sprintf("%T", err))) + } + return source, language, err +} + // activeConfigFilePath returns the configuration FILE this server belongs to: // the path declared at construction (WithConfigFilePath — every production // surface passes it), else the running server's own resolution. @@ -529,7 +559,26 @@ func (p *MCPProxyServer) scriptsDir() string { if configFilePath == "" && p.config != nil { configFilePath = config.GetConfigPath(p.config.DataDir) } - return codescripts.DirFor(configFilePath) + dir := codescripts.DirFor(configFilePath) + if warmed := p.warmedScriptsDir.Load(); warmed != nil && *warmed != dir && p.warmedScriptsDir.CompareAndSwap(warmed, &dir) { + // The active config file moved: warm the new directory's index off + // this (possibly scoped) request's goroutine, once. + go p.warmStoredScripts(dir) + } + return dir +} + +// warmStoredScripts builds the stored-name index of dir the scoped resolver +// answers from (Spec 105 FR-012) — synchronously on the caller's goroutine, +// which is never a request's: construction, or a goroutine of its own when +// the directory moves. A directory that cannot be indexed (usually: not +// created yet) refuses scoped callers until it changes; the administrator's +// resolution does not depend on the index at all. +func (p *MCPProxyServer) warmStoredScripts(dir string) { + if err := codescripts.Warm(dir); err != nil { + p.logger.Debug("Stored-script index not built; scoped callers are refused until the directory changes (Spec 105 FR-012)", + zap.String("dir", dir), zap.Error(err)) + } } // codeExecRecordArguments builds the argument payload recorded for a diff --git a/internal/server/mcp_code_scripts_test.go b/internal/server/mcp_code_scripts_test.go index 5a957a3ea..35ebe1b50 100644 --- a/internal/server/mcp_code_scripts_test.go +++ b/internal/server/mcp_code_scripts_test.go @@ -6,18 +6,22 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "testing" + "time" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" "github.com/smart-mcp-proxy/mcpproxy-go/internal/cache" "github.com/smart-mcp-proxy/mcpproxy-go/internal/codescripts" "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" "github.com/smart-mcp-proxy/mcpproxy-go/internal/index" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/jsruntime" "github.com/smart-mcp-proxy/mcpproxy-go/internal/profile" "github.com/smart-mcp-proxy/mcpproxy-go/internal/secret" "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" @@ -30,6 +34,25 @@ import ( // returns it with the scripts directory that authority implies. func newStoredScriptProxy(t *testing.T, opts ...MCPProxyOption) (*MCPProxyServer, string) { t.Helper() + return newStoredScriptProxyCfg(t, nil, opts...) +} + +// newStoredScriptProxyCfg is newStoredScriptProxy with a hook to edit the +// config BEFORE the proxy is constructed, for fixtures that need a +// construction-time setting (mcp-go fixes WithInstructions on the server +// instance, so a post-construction edit would not reach initialize). +func newStoredScriptProxyCfg(t *testing.T, configure func(*config.Config), opts ...MCPProxyOption) (*MCPProxyServer, string) { + t.Helper() + + // The stored-name index (Linux/BSD) only authorizes a hit once it is + // SETTLED — its generation stamp must predate the listing by at least + // codescripts' settle window (round 9 MUST-FIX), because a directory's + // ctime cannot be forged from user space to fake settledness. These + // fixtures write scripts and resolve them within the same test, so the + // clock the settle check reads is moved ahead instead of sleeping out + // the real window on every case; darwin/Windows have no settle window + // and this is a no-op there. + t.Cleanup(codescripts.SetIndexClockForTest(func() time.Time { return time.Now().Add(time.Hour) })) tmpDir := t.TempDir() logger := zap.NewNop() @@ -46,6 +69,9 @@ func newStoredScriptProxy(t *testing.T, opts ...MCPProxyOption) (*MCPProxyServer cfg.DataDir = tmpDir cfg.EnableCodeExecution = true cfg.CodeExecutionPoolSize = 1 + if configure != nil { + configure(cfg) + } um := upstream.NewManager(logger, cfg, sm.GetBoltDB(), secret.NewResolver(), sm) @@ -66,9 +92,16 @@ func newStoredScriptProxy(t *testing.T, opts ...MCPProxyOption) (*MCPProxyServer return proxy, scriptsDir } +// writeStoredScript publishes one script and lets the scoped resolver's +// stored-name index catch up with it (Spec 105 FR-012): in production the +// index is refreshed off the request path milliseconds after the directory +// changes, and a scoped call in that window is refused fail-closed; the +// fixture lands that refresh deterministically instead of racing it. The +// administrator's resolution reads the directory itself and never waits. func writeStoredScript(t *testing.T, scriptsDir, filename, content string) { t.Helper() require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, filename), []byte(content), 0o644)) + require.NoError(t, codescripts.Warm(scriptsDir)) } // callCodeExecution runs the code_execution handler and returns the result. @@ -294,8 +327,10 @@ func TestCodeExecution_ScriptLanguageContradiction(t *testing.T) { assert.False(t, ok.IsError, "an agreeing language must not be rejected: %s", resultText(t, ok)) } -// TestCodeExecution_ScriptNotFoundListsAvailable pins FR-004: the not-found -// error IS the MCP discovery mechanism. +// TestCodeExecution_ScriptNotFoundListsAvailable pins Spec 097 FR-004: the +// not-found error IS the MCP discovery mechanism — for the in-process caller +// with no auth context (an administrator). Kept as the Spec 105 FR-012 ADMIN +// CONTROL; the agent-token cell is TestCodeExecution_ScriptNotFound_AgentTokenNonDisclosing. func TestCodeExecution_ScriptNotFoundListsAvailable(t *testing.T) { proxy, scriptsDir := newStoredScriptProxy(t) writeStoredScript(t, scriptsDir, "alpha.js", "1") @@ -315,6 +350,265 @@ func TestCodeExecution_ScriptNotFoundListsAvailable(t *testing.T) { }) } +// callCodeExecutionAs is callCodeExecution under an explicit caller context. +func callCodeExecutionAs(t *testing.T, ctx context.Context, proxy *MCPProxyServer, args map[string]interface{}) *mcp.CallToolResult { + t.Helper() + request := mcp.CallToolRequest{Params: mcp.CallToolParams{Name: "code_execution", Arguments: args}} + result, err := proxy.handleCodeExecution(ctx, request) + require.NoError(t, err) + require.NotNil(t, result) + return result +} + +// callCodeExecutionOnWire drives a routing-mode server through the JSON-RPC +// seam (initialize, then tools/call code_execution) under ctx and returns the +// decoded tools/call result object — the exact bytes an HTTP caller of that +// surface receives. +func callCodeExecutionOnWire(t *testing.T, ctx context.Context, srv jsonRPCHandler, args map[string]interface{}) (isError bool, text string) { + t.Helper() + require.NotNil(t, srv.HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}`))) + rawArgs, err := json.Marshal(args) + require.NoError(t, err) + encoded, err := json.Marshal(srv.HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"code_execution","arguments":`+string(rawArgs)+`}}`))) + require.NoError(t, err) + var envelope struct { + Error *json.RawMessage `json:"error"` + Result *struct { + IsError bool `json:"isError"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(encoded, &envelope)) + require.Nil(t, envelope.Error, "tools/call must answer with a result, not a JSON-RPC error: %s", encoded) + require.NotNil(t, envelope.Result) + require.NotEmpty(t, envelope.Result.Content) + return envelope.Result.IsError, envelope.Result.Content[0].Text +} + +// TestCodeExecution_ScriptNotFound_AgentTokenNonDisclosing (Spec 105 T062, +// FR01x-G1, spec.md:116): a missing-script request under an agent token is +// a NON-DISCLOSING refusal — it names neither the other stored scripts nor +// how many there are — and it is byte-equal to the refusal the same caller +// gets when the directory is empty, so a failed call is not an oracle for +// what is stored. The administrator keeps today's enumeration (SC-005). +// +// The unrestricted ["*"] token is the strongest cell: server scope plays no +// part, the caller KIND alone decides. +func TestCodeExecution_ScriptNotFound_AgentTokenNonDisclosing(t *testing.T) { + const sentinel = "SENTINEL" + scoped := agentCtx([]string{"*"}, []string{auth.PermRead, auth.PermWrite, auth.PermDestructive}, "") + + t.Run("agent token: no enumeration, byte-equal to the empty directory", func(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + + // Same proxy, same directory path, same caller: first with nothing + // stored, then with two scripts — the two refusals must not differ. + empty := callCodeExecutionAs(t, scoped, proxy, map[string]interface{}{"script": "gamma"}) + require.True(t, empty.IsError, "a missing script is an error for every caller") + emptyText := resultText(t, empty) + + writeStoredScript(t, scriptsDir, "alpha-"+sentinel+".js", "1") + writeStoredScript(t, scriptsDir, "beta.ts", "1") + + populated := callCodeExecutionAs(t, scoped, proxy, map[string]interface{}{"script": "gamma"}) + require.True(t, populated.IsError) + text := resultText(t, populated) + assert.Contains(t, text, "gamma", "the caller's own requested name may be echoed") + assert.NotContains(t, text, sentinel, "an agent-token caller must not learn other script names (FR-012)") + assert.NotContains(t, text, "beta", "an agent-token caller must not learn other script names (FR-012)") + assert.NotContains(t, text, "Available scripts", "an agent-token caller must not be handed an enumeration (FR-012)") + assert.NotContains(t, text, "(2)", "an agent-token caller must not learn the script count (FR-012)") + assert.Equal(t, emptyText, text, + "the agent-token refusal must be byte-equal whether the directory is empty or populated (no oracle)") + }) + + t.Run("administrator control: still enumerates", func(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "alpha-"+sentinel+".js", "1") + writeStoredScript(t, scriptsDir, "beta.ts", "1") + + result := callCodeExecutionAs(t, adminCtx(), proxy, map[string]interface{}{"script": "gamma"}) + require.True(t, result.IsError) + text := resultText(t, result) + assert.Contains(t, text, "alpha-"+sentinel) + assert.Contains(t, text, "beta") + assert.Contains(t, text, "Available scripts (2)", "the administrator keeps the Spec 097 FR-004 enumeration (SC-005)") + }) + + t.Run("wire level: /mcp/code and /mcp carry the same non-disclosing refusal", func(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "alpha-"+sentinel+".js", "1") + writeStoredScript(t, scriptsDir, "beta.ts", "1") + require.NotNil(t, proxy.codeExecServer, "fixture: the /mcp/code server must exist") + + for label, srv := range map[string]jsonRPCHandler{"code-exec": proxy.codeExecServer, "default": proxy.server} { + label, srv := label, srv + t.Run(label, func(t *testing.T) { + isError, text := callCodeExecutionOnWire(t, scoped, srv, map[string]interface{}{"script": "gamma"}) + require.True(t, isError, "%s: a missing script is an error: %s", label, text) + assert.NotContains(t, text, sentinel, "%s: agent-token refusal leaks a script name (FR-012)", label) + assert.NotContains(t, text, "Available scripts", "%s: agent-token refusal leaks the enumeration (FR-012)", label) + + adminErr, adminText := callCodeExecutionOnWire(t, adminCtx(), srv, map[string]interface{}{"script": "gamma"}) + require.True(t, adminErr) + assert.Contains(t, adminText, sentinel, "%s: the administrator keeps the enumeration", label) + }) + } + }) +} + +// TestCodeExecution_StoredScriptSiblingRefusals_AgentTokenNonDisclosing +// (critique r1 #3): the refusals that are NOT "not found" — an ambiguous +// name, a present-but-unusable file, an unreadable directory — speak about +// the operator's filesystem (the scripts directory and full host paths), and +// that is the same class of disclosure NonDisclosing strips from the +// not-found form. A scoped caller gets the name and the reason only; the +// administrator keeps the paths (SC-005). +func TestCodeExecution_StoredScriptSiblingRefusals_AgentTokenNonDisclosing(t *testing.T) { + scoped := agentCtx([]string{"*"}, []string{auth.PermRead, auth.PermWrite, auth.PermDestructive}, "") + + cells := []struct { + name string + prepare func(t *testing.T, scriptsDir string) + reason string // a fragment of the reason the scoped caller may still see + }{ + { + name: "ambiguous name", + prepare: func(t *testing.T, scriptsDir string) { + writeStoredScript(t, scriptsDir, "dup.js", "1") + writeStoredScript(t, scriptsDir, "dup.ts", "1") + }, + reason: "ambiguous", + }, + { + name: "empty file", + prepare: func(t *testing.T, scriptsDir string) { + writeStoredScript(t, scriptsDir, "dup.js", "") + }, + reason: codescripts.ReasonEmpty, + }, + { + name: "oversized file", + prepare: func(t *testing.T, scriptsDir string) { + writeStoredScript(t, scriptsDir, "dup.js", strings.Repeat("x", codescripts.MaxSizeBytes+1)) + }, + reason: codescripts.ReasonOversized, + }, + } + for _, cell := range cells { + cell := cell + t.Run(cell.name, func(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + cell.prepare(t, scriptsDir) + + result := callCodeExecutionAs(t, scoped, proxy, map[string]interface{}{"script": "dup"}) + require.True(t, result.IsError) + text := resultText(t, result) + assert.Contains(t, text, "dup", "the caller's own requested name may be echoed") + assert.Contains(t, text, cell.reason, "the reason is the caller's recovery path and stays") + assert.NotContains(t, text, scriptsDir, + "an agent-token refusal must not disclose the scripts directory or a host path (FR-012): %s", text) + + admin := callCodeExecutionAs(t, adminCtx(), proxy, map[string]interface{}{"script": "dup"}) + require.True(t, admin.IsError) + assert.Contains(t, resultText(t, admin), scriptsDir, "the administrator keeps the host path (SC-005)") + }) + } + + t.Run("unreadable directory", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod 0 does not make a directory unreadable on Windows") + } + if os.Geteuid() == 0 { + t.Skip("root ignores directory permissions") + } + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "dup.js", "1") + require.NoError(t, os.Chmod(scriptsDir, 0o000)) + t.Cleanup(func() { _ = os.Chmod(scriptsDir, 0o755) }) + + result := callCodeExecutionAs(t, scoped, proxy, map[string]interface{}{"script": "dup"}) + require.True(t, result.IsError) + text := resultText(t, result) + assert.Contains(t, text, codescripts.ReasonUnreadable) + assert.NotContains(t, text, scriptsDir, + "an agent-token refusal must not disclose the scripts directory (FR-012): %s", text) + assert.NotContains(t, text, "permission denied", + "the raw OS error is withheld from an agent-token caller: %s", text) + + admin := callCodeExecutionAs(t, adminCtx(), proxy, map[string]interface{}{"script": "dup"}) + require.True(t, admin.IsError) + assert.Contains(t, resultText(t, admin), scriptsDir, "the administrator keeps the directory and the OS error (SC-005)") + }) +} + +// TestCodeExecution_StoredScript_ScopedPositiveControls pins the two +// documented, PUBLISHED behaviours of spec.md:116 that bound FR-012: stored +// scripts are operator-published content — a scoped token may run one and +// receive any constant it returns without an upstream call — while every +// upstream call the script makes stays scope-checked, so a nested call to a +// server outside the token's scope is refused at the nested call (FR-009). +// Both cells hold on the merge base and are kept as regression pins. +func TestCodeExecution_StoredScript_ScopedPositiveControls(t *testing.T) { + aOnly := agentCtx([]string{"a"}, []string{auth.PermRead}, "") + + t.Run("a-only token runs a constant-returning script and gets the constant", func(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "constant.js", `({published: "operator-constant"})`) + + result := callCodeExecutionAs(t, aOnly, proxy, map[string]interface{}{"script": "constant"}) + require.False(t, result.IsError, resultText(t, result)) + assert.Contains(t, resultText(t, result), `"published":"operator-constant"`, + "a constant a stored script returns is published content, visible to a scoped caller (spec.md:116)") + }) + + // reachB is the stored script both nested-call cells run: it reports the + // nested call's outcome as data so the refusal can be compared byte for + // byte between a hidden and a nonexistent b. + const reachB = `var r = call_tool('b', 'private_search', {q: 'x'}); ({ok: r.ok, code: r.ok ? null : r.error.code, message: r.ok ? null : r.error.message})` + + t.Run("a stored script calling b is refused at the nested call", func(t *testing.T) { + // Cell 1 — b does not exist at all. + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "reach-b.js", reachB) + + result := callCodeExecutionAs(t, aOnly, proxy, map[string]interface{}{"script": "reach-b"}) + require.False(t, result.IsError, "the script itself runs; only its nested call is refused: %s", resultText(t, result)) + nonexistent := resultText(t, result) + assert.Contains(t, nonexistent, `"ok":false`) + assert.Contains(t, nonexistent, `"code":"`+string(jsruntime.ErrorCodeAccessDenied)+`"`, + "the nested call must be refused by the token's server scope, before any upstream lookup (FR-009): %s", nonexistent) + + // Cell 2 — b EXISTS, is connected and serves private_search (the + // spec fixture's hidden server). The a-only token's refusal must be + // byte-equal to cell 1 (a hidden b is indistinguishable from a + // nonexistent one) and b must witness zero calls; the administrator + // control proves the upstream is reachable. + hidden, rt := createTestProxyWithRuntimeCfg(t, nil, func(cfg *config.Config) { + cfg.EnableCodeExecution = true + cfg.CodeExecutionPoolSize = 1 + }) + b := startCountingUpstream(t, hidden, rt, "b", readSpec("private_search")) + hiddenScripts := hidden.scriptsDir() + require.NoError(t, os.MkdirAll(hiddenScripts, 0o755)) + writeStoredScript(t, hiddenScripts, "reach-b.js", reachB) + + result = callCodeExecutionAs(t, aOnly, hidden, map[string]interface{}{"script": "reach-b"}) + require.False(t, result.IsError, resultText(t, result)) + assert.Equal(t, nonexistent, resultText(t, result), + "a hidden b must be refused exactly as a nonexistent b (non-disclosing refusal)") + assert.Zero(t, b.count.Load(), "the refused nested call must never reach the hidden upstream") + + admin := callCodeExecutionAs(t, adminCtx(), hidden, map[string]interface{}{"script": "reach-b"}) + require.False(t, admin.IsError, resultText(t, admin)) + assert.Contains(t, resultText(t, admin), `"ok":true`, "administrator control: the same script reaches b: %s", resultText(t, admin)) + assert.Equal(t, int64(1), b.count.Load(), "administrator control: b witnesses the call") + }) +} + // TestCodeExecution_RecordsCarryScriptAndSource pins FR-005 / research R6: // history keeps the executed SOURCE as code (Spec 024 parity) and additionally // names the script. diff --git a/internal/server/mcp_instructions_scope_test.go b/internal/server/mcp_instructions_scope_test.go new file mode 100644 index 000000000..33278bb47 --- /dev/null +++ b/internal/server/mcp_instructions_scope_test.go @@ -0,0 +1,90 @@ +package server + +import ( + "context" + "encoding/json" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// Spec 105 FR-012 (PR H0, spec.md:116 "Operator-published content"): custom +// initialization `instructions` are operator-authored text published to EVERY +// caller by design, and sit OUTSIDE the semantic-disclosure guarantee. A +// scoped token therefore receives them verbatim — even when they mention a +// server it cannot reach — which is why the agent-token documentation warns +// operators not to put server names or secrets in them. This pins that +// documented behaviour so a later "scrub instructions per caller" change is +// a deliberate spec decision, not drift. It holds on the merge base. + +// newCustomInstructionsProxy builds a proxy whose config carries the +// operator's `instructions` at CONSTRUCTION time, on the shared stored-script +// fixture (mcp-go fixes WithInstructions on the server instance, so a +// post-construction edit would not reach initialize). +func newCustomInstructionsProxy(t *testing.T, instructions string) *MCPProxyServer { + t.Helper() + proxy, _ := newStoredScriptProxyCfg(t, func(cfg *config.Config) { cfg.Instructions = instructions }) + return proxy +} + +// initializeInstructions performs the JSON-RPC initialize handshake on srv +// under ctx and returns the `instructions` the caller is handed. +func initializeInstructions(t *testing.T, ctx context.Context, srv jsonRPCHandler) string { + t.Helper() + encoded, err := json.Marshal(srv.HandleMessage(ctx, []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}`))) + require.NoError(t, err) + var envelope struct { + Error *json.RawMessage `json:"error"` + Result struct { + Instructions string `json:"instructions"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(encoded, &envelope)) + require.Nil(t, envelope.Error, "initialize must succeed: %s", encoded) + return envelope.Result.Instructions +} + +// TestScopedInitialize_PublishesCustomInstructions (T062 positive control): +// a scoped initialization publishes the operator's custom instructions +// verbatim, including a mention of `b:private_search` on a server the token +// cannot reach — published, documented content (spec.md:116). +func TestScopedInitialize_PublishesCustomInstructions(t *testing.T) { + const custom = "Team conventions: run b:private_search before answering; never paste raw output." + proxy := newCustomInstructionsProxy(t, custom) + require.NotNil(t, proxy.directServer, "fixture: the direct server must exist") + + aOnly := agentCtx([]string{"a"}, []string{auth.PermRead}, "") + require.False(t, auth.AuthContextFromContext(aOnly).CanAccessServer("b"), "precondition: b is outside the token's scope") + + // The two surfaces that carry instructions today: the default /mcp server + // (resolveInstructions) and the direct server (resolveDirectInstructions, + // which appends its deferral legend to the operator's text). + for label, srv := range map[string]jsonRPCHandler{ + "default": proxy.server, + "direct": proxy.directServer, + } { + label, srv := label, srv + t.Run(label, func(t *testing.T) { + scoped := initializeInstructions(t, aOnly, srv) + assert.Contains(t, scoped, custom, + "%s: a scoped initialization must publish the operator's custom instructions verbatim (spec.md:116)", label) + assert.Contains(t, scoped, "b:private_search", + "%s: the mention of an out-of-scope server in operator-authored instructions is published by design — the docs warn operators, the proxy does not scrub", label) + + admin := initializeInstructions(t, adminCtx(), srv) + assert.Equal(t, admin, scoped, + "%s: instructions are the same text for every caller kind (SC-005)", label) + }) + } +} + +// jsonRPCHandler is the seam every routing-mode server exposes: the raw +// JSON-RPC message handler an HTTP transport feeds. +type jsonRPCHandler interface { + HandleMessage(context.Context, json.RawMessage) mcp.JSONRPCMessage +} diff --git a/internal/server/testdata/toolslist_goldens/code_execution_mode.json b/internal/server/testdata/toolslist_goldens/code_execution_mode.json index a3d175784..3dc25c486 100644 --- a/internal/server/testdata/toolslist_goldens/code_execution_mode.json +++ b/internal/server/testdata/toolslist_goldens/code_execution_mode.json @@ -7,7 +7,7 @@ "idempotentHint": false, "openWorldHint": true }, - "description": "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 (\u003e2 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**Stored scripts**: Instead of `code`, pass `script: \"\u003cname\u003e\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. Naming a script that does not exist returns the available names, which is how you discover what is stored.\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.", + "description": "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 (\u003e2 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**Stored scripts**: Instead of `code`, pass `script: \"\u003cname\u003e\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. The stored-script listing is administrator-only (`mcpproxy code scripts list`, or the not-found error under the admin API key); an agent-token caller must already know the script name — a name that does not exist is refused without naming what is stored.\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.", "inputSchema": { "properties": { "code": { @@ -33,7 +33,7 @@ "type": "object" }, "script": { - "description": "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `\u003cname\u003e.js` / `\u003cname\u003e.ts` files in the `scripts/` directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. DISCOVERY: calling with a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total), so the current set can always be recovered from a single failed call. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code.", + "description": "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `\u003cname\u003e.js` / `\u003cname\u003e.ts` files in the `scripts/` directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. ENUMERATION IS ADMINISTRATOR-ONLY: for an administrator (the admin API key, the tray, an in-process caller) a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total); an agent-token caller must already know the script name — its not-found error names neither the stored scripts nor how many there are. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code.", "type": "string" } }, diff --git a/internal/server/testdata/toolslist_goldens/default_server.json b/internal/server/testdata/toolslist_goldens/default_server.json index 8e860dbf1..8083efeb1 100644 --- a/internal/server/testdata/toolslist_goldens/default_server.json +++ b/internal/server/testdata/toolslist_goldens/default_server.json @@ -127,7 +127,7 @@ "idempotentHint": false, "openWorldHint": true }, - "description": "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 (\u003e2 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**Stored scripts**: Instead of `code`, pass `script: \"\u003cname\u003e\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. Naming a script that does not exist returns the available names, which is how you discover what is stored.\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.", + "description": "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 (\u003e2 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**Stored scripts**: Instead of `code`, pass `script: \"\u003cname\u003e\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. The stored-script listing is administrator-only (`mcpproxy code scripts list`, or the not-found error under the admin API key); an agent-token caller must already know the script name — a name that does not exist is refused without naming what is stored.\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.", "inputSchema": { "properties": { "code": { @@ -153,7 +153,7 @@ "type": "object" }, "script": { - "description": "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `\u003cname\u003e.js` / `\u003cname\u003e.ts` files in the `scripts/` directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. DISCOVERY: calling with a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total), so the current set can always be recovered from a single failed call. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code.", + "description": "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `\u003cname\u003e.js` / `\u003cname\u003e.ts` files in the `scripts/` directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. ENUMERATION IS ADMINISTRATOR-ONLY: for an administrator (the admin API key, the tray, an in-process caller) a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total); an agent-token caller must already know the script name — its not-found error names neither the stored scripts nor how many there are. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code.", "type": "string" } }, diff --git a/internal/server/testdata/toolslist_goldens/pre105/code_execution_mode.json b/internal/server/testdata/toolslist_goldens/pre105/code_execution_mode.json new file mode 100644 index 000000000..a3d175784 --- /dev/null +++ b/internal/server/testdata/toolslist_goldens/pre105/code_execution_mode.json @@ -0,0 +1,324 @@ +{ + "code_execution": { + "annotations": { + "title": "Code Execution", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "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 (\u003e2 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**Stored scripts**: Instead of `code`, pass `script: \"\u003cname\u003e\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. Naming a script that does not exist returns the available names, which is how you discover what is stored.\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.", + "inputSchema": { + "properties": { + "code": { + "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, `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()})`", + "type": "string" + }, + "input": { + "description": "Input data accessible as global `input` variable in code (default: {})", + "properties": {}, + "type": "object" + }, + "language": { + "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'.", + "enum": [ + "javascript", + "typescript" + ], + "type": "string" + }, + "options": { + "description": "Execution options: timeout_ms (1-600000, default: 120000), max_tool_calls (\u003e= 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}).", + "properties": {}, + "type": "object" + }, + "script": { + "description": "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `\u003cname\u003e.js` / `\u003cname\u003e.ts` files in the `scripts/` directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. DISCOVERY: calling with a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total), so the current set can always be recovered from a single failed call. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code.", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "code_execution" + }, + "list_registries": { + "annotations": { + "title": "List Registries", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "📋 List all available MCP registries. Use this FIRST to discover which registries you can search with the 'search_servers' tool. Each registry contains different collections of MCP servers that can be added as upstreams.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "list_registries" + }, + "quarantine_security": { + "annotations": { + "title": "Quarantine Security", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Security quarantine management AND TPA scanning for MCP servers and tools. Review and manage quarantined servers and tools to prevent Tool Poisoning Attacks (TPAs), and scan a server for them: 'scan_server' runs the always-on offline baseline scan (in-process, no Docker required) and 'get_scan_report' returns the latest verdict and findings. Every listing/inspection response also carries a one-line scan status, so an unscanned server is visible as unscanned. Supports server-level quarantine and tool-level approval for individual tool description/schema changes. NOTE: Unquarantining servers is only available through manual config editing or system tray UI for security.", + "inputSchema": { + "properties": { + "name": { + "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, approve_prompt, approve_all_prompts, scan_server, get_scan_report)", + "type": "string" + }, + "operation": { + "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool, inspect_prompts, approve_prompt, approve_all_prompts, scan_server, get_scan_report. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled. The prompt operations (spec 100) manage aggregated upstream prompts held by the metadata rug-pull baseline: a prompt whose advertised metadata changed since approval is withheld from prompts/list until approved. 'scan_server' starts the offline TPA baseline scan for one server (no Docker needed) and returns the verdict once it settles, or a job id to poll; 'get_scan_report' returns that server's latest verdict, counts and findings.", + "enum": [ + "list_quarantined", + "inspect_quarantined", + "quarantine_server", + "inspect_tools", + "approve_tool", + "approve_all_tools", + "block_tool", + "block_all_tools", + "enable_tool", + "disable_tool", + "inspect_prompts", + "approve_prompt", + "approve_all_prompts", + "scan_server", + "get_scan_report" + ], + "type": "string" + }, + "prompt_name": { + "description": "Prompt name (required for approve_prompt; spec 100)", + "type": "string" + }, + "tool_name": { + "description": "Tool name (required for approve_tool and block_tool operations)", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + "name": "quarantine_security" + }, + "retrieve_tools": { + "annotations": { + "title": "Retrieve Tools", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Search and discover available upstream tools using BM25 full-text search. Use this to find tools, then use the `code_execution` tool to call them via `call_tool(serverName, toolName, args)` in JavaScript. Do NOT use call_tool_read/write/destructive — they are not available in this mode. Use natural language to describe what you want to accomplish. Response includes a structured `session_risk` object (level, lethal_trifecta, has_open_world_tools, has_destructive_tools, has_write_tools). ANNOTATION FILTERS: read_only_only, exclude_destructive and exclude_open_world self-restrict discovery. When they withhold tools that matched your query, the response carries a 'filter_diagnostics' block with per-filter counts (split into missing upstream annotations vs. explicitly unsafe ones) and one suggestion; it is absent when nothing was withheld. Filter diagnostics describe this call's candidate window, not the whole catalog.", + "inputSchema": { + "properties": { + "exclude_destructive": { + "description": "Exclude tools with destructiveHint=true or unset (MCP default is destructive). Use to avoid destructive operations.", + "type": "boolean" + }, + "exclude_open_world": { + "description": "Exclude tools with openWorldHint=true or unset (MCP default is open-world). Use to restrict to local/sandboxed tools.", + "type": "boolean" + }, + "include_session_risk_warning": { + "description": "Include the prose 'warning' string in session_risk when the lethal trifecta is detected (default: false; structured fields are always returned). Server-side default can be flipped via the 'tool_response_session_risk_warning' config flag.", + "type": "boolean" + }, + "limit": { + "description": "Maximum number of tools to return (default: configured tools_limit, max: 100)", + "type": "number" + }, + "query": { + "description": "Natural language description of what you want to accomplish.", + "type": "string" + }, + "read_only_only": { + "description": "Only return tools with readOnlyHint=true. Use to self-restrict to safe read operations.", + "type": "boolean" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "retrieve_tools" + }, + "search_servers": { + "annotations": { + "title": "Search Servers", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "🔍 Discover MCP servers from known registries with repository type detection. Search and filter servers from embedded registry list to find new MCP servers that can be added as upstreams. Features npm/PyPI package detection for enhanced install commands. WORKFLOW: 1) Call 'list_registries' first to see available registries, 2) Use this tool with a registry ID to search servers. Results include server URLs and repository information ready for direct use with upstream_servers add command.", + "inputSchema": { + "properties": { + "limit": { + "description": "Maximum number of results to return (default: 10, max: 50)", + "type": "number" + }, + "registry": { + "description": "Registry ID or name to search (e.g., 'smithery', 'mcprun', 'pulse'). Use 'list_registries' tool first to see available registries.", + "type": "string" + }, + "search": { + "description": "Search term to filter servers by name or description (case-insensitive)", + "type": "string" + }, + "tag": { + "description": "Filter servers by tag/category (if supported by registry)", + "type": "string" + } + }, + "required": [ + "registry" + ], + "type": "object" + }, + "name": "search_servers" + }, + "set_profile": { + "annotations": { + "title": "Set Profile", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Switch the active profile for THIS session. A profile scopes tool discovery (retrieve_tools) and tool calls to a named subset of upstream servers — useful to focus an agent on one task domain (e.g. 'research', 'deploy'). The selection persists for the lifetime of the current MCP session and applies to subsequent retrieve_tools / call_tool_* / code_execution calls on the base /mcp endpoint without re-indexing. Pass an empty string to clear the selection and go back to all servers. Note: an explicit /mcp/p/\u003cslug\u003e URL still overrides the session profile for that request, and a profile-pinned agent token cannot switch away from its pinned profile.", + "inputSchema": { + "properties": { + "profile": { + "description": "Profile slug to activate for this session (e.g. 'research'). Pass \"\" (empty) to clear the active profile and return to all servers.", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "set_profile" + }, + "upstream_servers": { + "annotations": { + "title": "Upstream Servers", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Manage upstream MCP servers - add, remove, update, and list servers. Includes Docker isolation configuration and connection status monitoring. SECURITY: Newly added servers are automatically quarantined to prevent Tool Poisoning Attacks (TPAs). Use 'quarantine_security' tool to review and manage quarantined servers. NOTE: Unquarantining servers is only available through manual config editing or system tray UI for security.\n\nDocker Isolation: Use 'isolation_json' parameter to configure per-server Docker images, CPU/memory limits, and network isolation. Example: {\"enabled\": true, \"image\": \"node:20\", \"network_mode\": \"bridge\"}.\n\nSMART PATCHING (update/patch): Uses deep merge - only specify fields you want to change. Omitted fields are PRESERVED, not removed. Examples:\n- Enable server: {\"operation\": \"patch\", \"name\": \"my-server\", \"enabled\": true} - only enabled changes\n- Enable isolation: {\"operation\": \"patch\", \"name\": \"my-server\", \"isolation_json\": \"{\\\"enabled\\\": true}\"} - enables isolation with defaults\n- Update image: {\"operation\": \"patch\", \"name\": \"my-server\", \"isolation_json\": \"{\\\"image\\\": \\\"python:3.12\\\"}\"} - other isolation fields preserved\n- Add env var: env_json merges with existing vars\n- Replace args: args_json replaces entirely (arrays not merged)\n- Remove field: use 'null' (e.g., isolation_json: \"null\" removes isolation)\n\nREDACTION (update/patch): the returned 'changes' diff keeps every field PATH exact, but MASKS values under secret-bearing keys (env vars, headers, oauth secrets, credential-shaped argv tokens) in both this response and the activity log. Non-secret values round-trip unchanged; do not read a masked value back as what was stored.", + "inputSchema": { + "properties": { + "args_json": { + "description": "Command arguments for stdio servers as a JSON array of strings (e.g., '[\"mcp-server-sqlite\", \"--db-path\", \"/path/to/db\"]'). For update/patch: REPLACES all existing args (arrays are not merged).", + "type": "string" + }, + "command": { + "description": "Command to run for stdio servers (e.g., 'uvx', 'python')", + "type": "string" + }, + "enabled": { + "description": "Whether server should be enabled (default: true)", + "type": "boolean" + }, + "env_json": { + "description": "Environment variables for stdio servers as JSON object (e.g., '{\"API_KEY\": \"value\"}'). For update/patch: MERGES with existing vars (new keys added, existing keys updated).", + "type": "string" + }, + "expose_prompts": { + "description": "Per-server prompt-aggregation override (F9): true = include this server's MCP prompts in mcpproxy's aggregated prompts/list; false = exclude them regardless of capability. Omit to leave unchanged (patch) / inherit the default (aggregate if advertised). Only meaningful when aggregate_upstream_prompts is enabled globally. Used with add/update/patch.", + "type": "boolean" + }, + "headers_json": { + "description": "HTTP headers for authentication as JSON object (e.g., '{\"Authorization\": \"Bearer token\"}'). For update/patch: MERGES with existing headers (new keys added, existing keys updated).", + "type": "string" + }, + "id": { + "description": "Server id within the registry - required for add_from_registry.", + "type": "string" + }, + "init_timeout": { + "description": "Per-server MCP `initialize` handshake deadline as a duration string (e.g. '120s', '3m'). Raise this for upstreams that do legitimate first-run warmup (cache/index build) before responding to `initialize`, so they are not killed mid-startup. Unset → global default (30s). Bounds: 1s–30m. Used with add/update/patch.", + "type": "string" + }, + "isolation_json": { + "description": "Docker isolation config as JSON object. MERGES with existing settings - only provided fields change. Use 'null' to remove isolation entirely. Example: '{\"image\": \"python:3.12\"}' updates only the image.", + "type": "string" + }, + "lines": { + "description": "Number of lines to tail from server log (default: 50, max: 500) - used with tail_log operation", + "type": "number" + }, + "name": { + "description": "Server name (required for add/remove/update/patch/tail_log operations; optional name override for add_from_registry)", + "type": "string" + }, + "oauth_json": { + "description": "OAuth config as JSON object. MERGES with existing settings. Use 'null' to remove OAuth entirely. Fields: client_id, client_secret, scopes (array - replaces).", + "type": "string" + }, + "operation": { + "description": "Operation: list, add, remove, update, patch, tail_log, add_from_registry, enable, disable, restart, refresh. 'update' and 'patch' use smart merge - only specified fields change, others preserved. 'add_from_registry' adds an upstream from a registry reference (registry+id) so you need not hand-construct command/args/url - the server re-derives the runnable config and quarantines it. 'refresh' re-discovers and re-indexes a server's tools without changing any security state - use it to make just-approved tools searchable immediately. For quarantine operations, use the 'quarantine_security' tool.", + "enum": [ + "list", + "add", + "remove", + "update", + "patch", + "tail_log", + "add_from_registry", + "enable", + "disable", + "restart", + "refresh" + ], + "type": "string" + }, + "protocol": { + "description": "Transport protocol: stdio, http, sse, streamable-http, auto (default: auto-detect)", + "enum": [ + "stdio", + "http", + "sse", + "streamable-http", + "auto" + ], + "type": "string" + }, + "registry": { + "description": "Registry id to add from (e.g. 'pulse') - required for add_from_registry. Use the 'list_registries'/'search_servers' tools to discover registries and server ids.", + "type": "string" + }, + "trust_mode": { + "description": "Per-server trust tier governing new-server admission AND tool-change approval (spec 086): 'auto' = approve without scanning; 'scan' = auto-approve only when the fast offline TPA scan is green, else hold for review; 'manual' = human reviews every change. Empty → manual (secure default). Used with add/update/patch.", + "enum": [ + "auto", + "scan", + "manual" + ], + "type": "string" + }, + "url": { + "description": "Server URL for HTTP/SSE servers (e.g., 'http://localhost:3001')", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + "name": "upstream_servers" + } +} diff --git a/internal/server/testdata/toolslist_goldens/pre105/default_server.json b/internal/server/testdata/toolslist_goldens/pre105/default_server.json new file mode 100644 index 000000000..8e860dbf1 --- /dev/null +++ b/internal/server/testdata/toolslist_goldens/pre105/default_server.json @@ -0,0 +1,544 @@ +{ + "call_tool_destructive": { + "annotations": { + "title": "Call Tool (Destructive)", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a DESTRUCTIVE tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: delete, remove, drop, revoke, disable, destroy, purge, reset, clear, unsubscribe, cancel, terminate, close, archive, ban, block, disconnect, kill, wipe, truncate, force, hard. Examples: delete_repo, remove_user, drop_table, revoke_access, clear_cache, terminate_session. Use for irreversible or high-impact operations. Result blocks may be prefixed by the marker line: [mcpproxy:toon/v1] TOON-encoded JSON (toon-format.org); decode to JSON before reuse - tool arguments must still be sent as JSON.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to the upstream tool as a native JSON object. Build arguments from the tool's compact signature ('sig') in retrieve_tools results — '*' marks required parameters, '~' marks a lossy signature (call describe_tool for the full JSON Schema before calling). Example: {\"path\": \"src/index.ts\", \"limit\": 20}. This is the preferred parameter — it eliminates JSON escaping overhead. Use 'args_json' only if your client cannot produce nested JSON objects.", + "properties": {}, + "type": "object" + }, + "args_json": { + "description": "Legacy: arguments as a pre-serialized JSON string. Prefer the 'args' parameter instead — it accepts a native JSON object and eliminates escaping overhead. If both are provided, 'args_json' wins for backward compatibility.", + "type": "string" + }, + "intent_data_sensitivity": { + "description": "Classify data being deleted: public, internal, private, or unknown. Important for tracking destructive operations on sensitive data.", + "type": "string" + }, + "intent_reason": { + "description": "Why is this deletion needed? Provide justification like 'User confirmed cleanup' or 'Removing obsolete data'.", + "type": "string" + }, + "name": { + "description": "Tool name in format 'server:tool' (e.g., 'github:delete_repo'). CRITICAL: You MUST use exact names from retrieve_tools results - do NOT guess or invent server names. Unknown servers will fail.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "call_tool_destructive" + }, + "call_tool_read": { + "annotations": { + "title": "Call Tool (Read)", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a READ-ONLY tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. Examples: search_files, get_user, list_repositories, query_database, find_issues, check_status. This is the DEFAULT choice when unsure - most tools are read-only. Result blocks may be prefixed by the marker line: [mcpproxy:toon/v1] TOON-encoded JSON (toon-format.org); decode to JSON before reuse - tool arguments must still be sent as JSON.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to the upstream tool as a native JSON object. Build arguments from the tool's compact signature ('sig') in retrieve_tools results — '*' marks required parameters, '~' marks a lossy signature (call describe_tool for the full JSON Schema before calling). Example: {\"path\": \"src/index.ts\", \"limit\": 20}. This is the preferred parameter — it eliminates JSON escaping overhead. Use 'args_json' only if your client cannot produce nested JSON objects.", + "properties": {}, + "type": "object" + }, + "args_json": { + "description": "Legacy: arguments as a pre-serialized JSON string. Prefer the 'args' parameter instead — it accepts a native JSON object and eliminates escaping overhead. If both are provided, 'args_json' wins for backward compatibility.", + "type": "string" + }, + "intent_data_sensitivity": { + "description": "Classify data being accessed: public, internal, private, or unknown. Helps track sensitive data access patterns.", + "type": "string" + }, + "intent_reason": { + "description": "Why is this tool being called? Provide context like 'User asked to check status' or 'Gathering data for report'.", + "type": "string" + }, + "name": { + "description": "Tool name in format 'server:tool' (e.g., 'github:get_user'). CRITICAL: You MUST use exact names from retrieve_tools results - do NOT guess or invent server names. Unknown servers will fail.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "call_tool_read" + }, + "call_tool_write": { + "annotations": { + "title": "Call Tool (Write)", + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a STATE-MODIFYING tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: create, update, modify, add, set, send, edit, change, write, post, put, patch, insert, upload, submit, assign, configure, enable, register, subscribe, publish, move, copy, rename, merge. Examples: create_issue, update_file, send_message, add_comment, set_status, edit_page. Use only when explicitly modifying state. Result blocks may be prefixed by the marker line: [mcpproxy:toon/v1] TOON-encoded JSON (toon-format.org); decode to JSON before reuse - tool arguments must still be sent as JSON.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to the upstream tool as a native JSON object. Build arguments from the tool's compact signature ('sig') in retrieve_tools results — '*' marks required parameters, '~' marks a lossy signature (call describe_tool for the full JSON Schema before calling). Example: {\"path\": \"src/index.ts\", \"limit\": 20}. This is the preferred parameter — it eliminates JSON escaping overhead. Use 'args_json' only if your client cannot produce nested JSON objects.", + "properties": {}, + "type": "object" + }, + "args_json": { + "description": "Legacy: arguments as a pre-serialized JSON string. Prefer the 'args' parameter instead — it accepts a native JSON object and eliminates escaping overhead. If both are provided, 'args_json' wins for backward compatibility.", + "type": "string" + }, + "intent_data_sensitivity": { + "description": "Classify data being modified: public, internal, private, or unknown. Helps track sensitive data changes.", + "type": "string" + }, + "intent_reason": { + "description": "Why is this modification needed? Provide context like 'User requested update' or 'Fixing reported issue'.", + "type": "string" + }, + "name": { + "description": "Tool name in format 'server:tool' (e.g., 'github:create_issue'). CRITICAL: You MUST use exact names from retrieve_tools results - do NOT guess or invent server names. Unknown servers will fail.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "call_tool_write" + }, + "code_execution": { + "annotations": { + "title": "Code Execution", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "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 (\u003e2 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**Stored scripts**: Instead of `code`, pass `script: \"\u003cname\u003e\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. Naming a script that does not exist returns the available names, which is how you discover what is stored.\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.", + "inputSchema": { + "properties": { + "code": { + "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, `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()})`", + "type": "string" + }, + "input": { + "description": "Input data accessible as global `input` variable in code (default: {})", + "properties": {}, + "type": "object" + }, + "language": { + "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'.", + "enum": [ + "javascript", + "typescript" + ], + "type": "string" + }, + "options": { + "description": "Execution options: timeout_ms (1-600000, default: 120000), max_tool_calls (\u003e= 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}).", + "properties": {}, + "type": "object" + }, + "script": { + "description": "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `\u003cname\u003e.js` / `\u003cname\u003e.ts` files in the `scripts/` directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. DISCOVERY: calling with a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total), so the current set can always be recovered from a single failed call. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code.", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "code_execution" + }, + "describe_tool": { + "annotations": { + "title": "Describe Tool", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Return full JSON Schema + long description for listed tools. Use when a signature is marked lossy ('~') or you need the exact schema before calling. With check:true it returns one availability verdict per id, not schemas ('ready', or a reason code with retryable/action), to gate a plan before its first call.", + "inputSchema": { + "properties": { + "check": { + "description": "Check availability only, no schemas (default false).", + "type": "boolean" + }, + "filters": { + "description": "check:true only. Annotation filters.", + "properties": { + "exclude_destructive": { + "type": "boolean" + }, + "exclude_open_world": { + "type": "boolean" + }, + "read_only_only": { + "type": "boolean" + } + }, + "type": "object" + }, + "tool_ids": { + "description": "Tool ids as listed: '\u003cserver\u003e:\u003ctool\u003e' or '\u003cserver\u003e__\u003ctool\u003e'. Max 5, or 50 with check:true.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "tool_ids" + ], + "type": "object" + }, + "name": "describe_tool" + }, + "list_registries": { + "annotations": { + "title": "List Registries", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "📋 List all available MCP registries. Use this FIRST to discover which registries you can search with the 'search_servers' tool. Each registry contains different collections of MCP servers that can be added as upstreams.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "list_registries" + }, + "quarantine_security": { + "annotations": { + "title": "Quarantine Security", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Security quarantine management AND TPA scanning for MCP servers and tools. Review and manage quarantined servers and tools to prevent Tool Poisoning Attacks (TPAs), and scan a server for them: 'scan_server' runs the always-on offline baseline scan (in-process, no Docker required) and 'get_scan_report' returns the latest verdict and findings. Every listing/inspection response also carries a one-line scan status, so an unscanned server is visible as unscanned. Supports server-level quarantine and tool-level approval for individual tool description/schema changes. NOTE: Unquarantining servers is only available through manual config editing or system tray UI for security.", + "inputSchema": { + "properties": { + "name": { + "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, approve_prompt, approve_all_prompts, scan_server, get_scan_report)", + "type": "string" + }, + "operation": { + "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool, inspect_prompts, approve_prompt, approve_all_prompts, scan_server, get_scan_report. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled. The prompt operations (spec 100) manage aggregated upstream prompts held by the metadata rug-pull baseline: a prompt whose advertised metadata changed since approval is withheld from prompts/list until approved. 'scan_server' starts the offline TPA baseline scan for one server (no Docker needed) and returns the verdict once it settles, or a job id to poll; 'get_scan_report' returns that server's latest verdict, counts and findings.", + "enum": [ + "list_quarantined", + "inspect_quarantined", + "quarantine_server", + "inspect_tools", + "approve_tool", + "approve_all_tools", + "block_tool", + "block_all_tools", + "enable_tool", + "disable_tool", + "inspect_prompts", + "approve_prompt", + "approve_all_prompts", + "scan_server", + "get_scan_report" + ], + "type": "string" + }, + "prompt_name": { + "description": "Prompt name (required for approve_prompt; spec 100)", + "type": "string" + }, + "tool_name": { + "description": "Tool name (required for approve_tool and block_tool operations)", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + "name": "quarantine_security" + }, + "read_cache": { + "annotations": { + "title": "Read Cache", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Retrieve paginated data when mcpproxy indicates a tool response was truncated. Use the cache key provided in truncation messages to access the complete dataset with pagination.", + "inputSchema": { + "properties": { + "key": { + "description": "Cache key provided by mcpproxy when a response was truncated (e.g. 'Use read_cache tool: key=\"abc123def...\"')", + "type": "string" + }, + "limit": { + "description": "Maximum number of records to return per page (default: 50, max: 1000)", + "type": "number" + }, + "offset": { + "description": "Starting record offset for pagination (default: 0)", + "type": "number" + } + }, + "required": [ + "key" + ], + "type": "object" + }, + "name": "read_cache" + }, + "retrieve_tools": { + "annotations": { + "title": "Retrieve Tools", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "🔍 CALL THIS FIRST to discover relevant tools! This is the primary tool discovery mechanism that searches across ALL upstream MCP servers using intelligent BM25 full-text search. Always use this before attempting to call any specific tools. Use natural language to describe what you want to accomplish (e.g., 'create GitHub repository', 'query database', 'weather forecast'). Results include 'annotations' (tool behavior hints like destructiveHint) and 'call_with' recommendation indicating which tool variant to use (call_tool_read/write/destructive). Then use the recommended variant with an 'intent' parameter. Compact mode returns one-line signatures ('sig': '*'=required, '~'=lossy) with first-sentence 'desc'; call describe_tool for full schemas. NOTE: Quarantined servers are excluded from search results for security. Use 'quarantine_security' tool to examine and manage quarantined servers. TO ADD NEW SERVERS: Use 'list_registries' then 'search_servers' to find and add new MCP servers. ANNOTATION FILTERS: read_only_only, exclude_destructive and exclude_open_world self-restrict discovery. When they withhold tools that matched your query, the response carries a 'filter_diagnostics' block with per-filter counts (split into missing upstream annotations vs. explicitly unsafe ones) and one suggestion; it is absent when nothing was withheld. Filter diagnostics describe this call's candidate window, not the whole catalog.", + "inputSchema": { + "properties": { + "debug": { + "description": "Enable debug mode with detailed scoring and ranking explanations (default: false)", + "type": "boolean" + }, + "detail": { + "description": "Per-call response serialization override: 'compact' returns one-line signatures (sig/desc/lossy) instead of full schemas; 'full' returns complete inputSchema entries. Unset: the server's configured tool_response_mode applies.", + "enum": [ + "compact", + "full" + ], + "type": "string" + }, + "exclude_destructive": { + "description": "Exclude tools with destructiveHint=true or unset (MCP default is destructive). Use to avoid destructive operations.", + "type": "boolean" + }, + "exclude_open_world": { + "description": "Exclude tools with openWorldHint=true or unset (MCP default is open-world). Use to restrict to local/sandboxed tools.", + "type": "boolean" + }, + "explain_tool": { + "description": "When debug=true, explain why a specific tool was ranked low (format: 'server:tool')", + "type": "string" + }, + "include_disabled": { + "description": "Set true to also surface tools that exist but are currently locked by config, user, or quarantine (default: false). Returns a 'disabled' list (name/server/description/status) plus a 'remediation' map; callable results are unaffected and listed first.", + "type": "boolean" + }, + "include_session_risk_warning": { + "description": "Include the prose 'warning' string in session_risk when the lethal trifecta is detected (default: false; structured fields are always returned). Server-side default can be flipped via the 'tool_response_session_risk_warning' config flag.", + "type": "boolean" + }, + "include_stats": { + "description": "Include usage statistics for returned tools (default: false)", + "type": "boolean" + }, + "limit": { + "description": "Maximum number of tools to return (default: configured tools_limit, max: 100)", + "type": "number" + }, + "query": { + "description": "Natural language description of what you want to accomplish. Be specific about your task (e.g., 'create a new GitHub repository', 'get weather for London', 'query SQLite database for users'). The search will find the most relevant tools across all connected servers.", + "type": "string" + }, + "read_only_only": { + "description": "Only return tools with readOnlyHint=true. Use to self-restrict to safe read operations.", + "type": "boolean" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "retrieve_tools" + }, + "search_servers": { + "annotations": { + "title": "Search Servers", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "🔍 Discover MCP servers from known registries with repository type detection. Search and filter servers from embedded registry list to find new MCP servers that can be added as upstreams. Features npm/PyPI package detection for enhanced install commands. WORKFLOW: 1) Call 'list_registries' first to see available registries, 2) Use this tool with a registry ID to search servers. Results include server URLs and repository information ready for direct use with upstream_servers add command.", + "inputSchema": { + "properties": { + "limit": { + "description": "Maximum number of results to return (default: 10, max: 50)", + "type": "number" + }, + "registry": { + "description": "Registry ID or name to search (e.g., 'smithery', 'mcprun', 'pulse'). Use 'list_registries' tool first to see available registries.", + "type": "string" + }, + "search": { + "description": "Search term to filter servers by name or description (case-insensitive)", + "type": "string" + }, + "tag": { + "description": "Filter servers by tag/category (if supported by registry)", + "type": "string" + } + }, + "required": [ + "registry" + ], + "type": "object" + }, + "name": "search_servers" + }, + "set_profile": { + "annotations": { + "title": "Set Profile", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Switch the active profile for THIS session. A profile scopes tool discovery (retrieve_tools) and tool calls to a named subset of upstream servers — useful to focus an agent on one task domain (e.g. 'research', 'deploy'). The selection persists for the lifetime of the current MCP session and applies to subsequent retrieve_tools / call_tool_* / code_execution calls on the base /mcp endpoint without re-indexing. Pass an empty string to clear the selection and go back to all servers. Note: an explicit /mcp/p/\u003cslug\u003e URL still overrides the session profile for that request, and a profile-pinned agent token cannot switch away from its pinned profile.", + "inputSchema": { + "properties": { + "profile": { + "description": "Profile slug to activate for this session (e.g. 'research'). Pass \"\" (empty) to clear the active profile and return to all servers.", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "set_profile" + }, + "upstream_servers": { + "annotations": { + "title": "Upstream Servers", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Manage upstream MCP servers - add, remove, update, and list servers. Includes Docker isolation configuration and connection status monitoring. SECURITY: Newly added servers are automatically quarantined to prevent Tool Poisoning Attacks (TPAs). Use 'quarantine_security' tool to review and manage quarantined servers. NOTE: Unquarantining servers is only available through manual config editing or system tray UI for security.\n\nDocker Isolation: Use 'isolation_json' parameter to configure per-server Docker images, CPU/memory limits, and network isolation. Example: {\"enabled\": true, \"image\": \"node:20\", \"network_mode\": \"bridge\"}.\n\nSMART PATCHING (update/patch): Uses deep merge - only specify fields you want to change. Omitted fields are PRESERVED, not removed. Examples:\n- Enable server: {\"operation\": \"patch\", \"name\": \"my-server\", \"enabled\": true} - only enabled changes\n- Enable isolation: {\"operation\": \"patch\", \"name\": \"my-server\", \"isolation_json\": \"{\\\"enabled\\\": true}\"} - enables isolation with defaults\n- Update image: {\"operation\": \"patch\", \"name\": \"my-server\", \"isolation_json\": \"{\\\"image\\\": \\\"python:3.12\\\"}\"} - other isolation fields preserved\n- Add env var: env_json merges with existing vars\n- Replace args: args_json replaces entirely (arrays not merged)\n- Remove field: use 'null' (e.g., isolation_json: \"null\" removes isolation)\n\nREDACTION (update/patch): the returned 'changes' diff keeps every field PATH exact, but MASKS values under secret-bearing keys (env vars, headers, oauth secrets, credential-shaped argv tokens) in both this response and the activity log. Non-secret values round-trip unchanged; do not read a masked value back as what was stored.", + "inputSchema": { + "properties": { + "args_json": { + "description": "Command arguments for stdio servers as a JSON array of strings (e.g., '[\"mcp-server-sqlite\", \"--db-path\", \"/path/to/db\"]'). For update/patch: REPLACES all existing args (arrays are not merged).", + "type": "string" + }, + "command": { + "description": "Command to run for stdio servers (e.g., 'uvx', 'python')", + "type": "string" + }, + "enabled": { + "description": "Whether server should be enabled (default: true)", + "type": "boolean" + }, + "env_json": { + "description": "Environment variables for stdio servers as JSON object (e.g., '{\"API_KEY\": \"value\"}'). For update/patch: MERGES with existing vars (new keys added, existing keys updated).", + "type": "string" + }, + "expose_prompts": { + "description": "Per-server prompt-aggregation override (F9): true = include this server's MCP prompts in mcpproxy's aggregated prompts/list; false = exclude them regardless of capability. Omit to leave unchanged (patch) / inherit the default (aggregate if advertised). Only meaningful when aggregate_upstream_prompts is enabled globally. Used with add/update/patch.", + "type": "boolean" + }, + "headers_json": { + "description": "HTTP headers for authentication as JSON object (e.g., '{\"Authorization\": \"Bearer token\"}'). For update/patch: MERGES with existing headers (new keys added, existing keys updated).", + "type": "string" + }, + "id": { + "description": "Server id within the registry - required for add_from_registry.", + "type": "string" + }, + "init_timeout": { + "description": "Per-server MCP `initialize` handshake deadline as a duration string (e.g. '120s', '3m'). Raise this for upstreams that do legitimate first-run warmup (cache/index build) before responding to `initialize`, so they are not killed mid-startup. Unset → global default (30s). Bounds: 1s–30m. Used with add/update/patch.", + "type": "string" + }, + "isolation_json": { + "description": "Docker isolation config as JSON object. MERGES with existing settings - only provided fields change. Use 'null' to remove isolation entirely. Example: '{\"image\": \"python:3.12\"}' updates only the image.", + "type": "string" + }, + "lines": { + "description": "Number of lines to tail from server log (default: 50, max: 500) - used with tail_log operation", + "type": "number" + }, + "name": { + "description": "Server name (required for add/remove/update/patch/tail_log operations; optional name override for add_from_registry)", + "type": "string" + }, + "oauth_json": { + "description": "OAuth config as JSON object. MERGES with existing settings. Use 'null' to remove OAuth entirely. Fields: client_id, client_secret, scopes (array - replaces).", + "type": "string" + }, + "operation": { + "description": "Operation: list, add, remove, update, patch, tail_log, add_from_registry, enable, disable, restart, refresh. 'update' and 'patch' use smart merge - only specified fields change, others preserved. 'add_from_registry' adds an upstream from a registry reference (registry+id) so you need not hand-construct command/args/url - the server re-derives the runnable config and quarantines it. 'refresh' re-discovers and re-indexes a server's tools without changing any security state - use it to make just-approved tools searchable immediately. For quarantine operations, use the 'quarantine_security' tool.", + "enum": [ + "list", + "add", + "remove", + "update", + "patch", + "tail_log", + "add_from_registry", + "enable", + "disable", + "restart", + "refresh" + ], + "type": "string" + }, + "protocol": { + "description": "Transport protocol: stdio, http, sse, streamable-http, auto (default: auto-detect)", + "enum": [ + "stdio", + "http", + "sse", + "streamable-http", + "auto" + ], + "type": "string" + }, + "registry": { + "description": "Registry id to add from (e.g. 'pulse') - required for add_from_registry. Use the 'list_registries'/'search_servers' tools to discover registries and server ids.", + "type": "string" + }, + "trust_mode": { + "description": "Per-server trust tier governing new-server admission AND tool-change approval (spec 086): 'auto' = approve without scanning; 'scan' = auto-approve only when the fast offline TPA scan is green, else hold for review; 'manual' = human reviews every change. Empty → manual (secure default). Used with add/update/patch.", + "enum": [ + "auto", + "scan", + "manual" + ], + "type": "string" + }, + "url": { + "description": "Server URL for HTTP/SSE servers (e.g., 'http://localhost:3001')", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + "name": "upstream_servers" + } +} diff --git a/internal/server/testdata/toolslist_goldens/pre105/retrieve_tools_mode.json b/internal/server/testdata/toolslist_goldens/pre105/retrieve_tools_mode.json new file mode 100644 index 000000000..a3911775e --- /dev/null +++ b/internal/server/testdata/toolslist_goldens/pre105/retrieve_tools_mode.json @@ -0,0 +1,540 @@ +{ + "call_tool_destructive": { + "annotations": { + "title": "Call Tool (Destructive)", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a DESTRUCTIVE tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: delete, remove, drop, revoke, disable, destroy, purge, reset, clear, unsubscribe, cancel, terminate, close, archive, ban, block, disconnect, kill, wipe, truncate, force, hard. Examples: delete_repo, remove_user, drop_table, revoke_access, clear_cache, terminate_session. Use for irreversible or high-impact operations. Result blocks may be prefixed by the marker line: [mcpproxy:toon/v1] TOON-encoded JSON (toon-format.org); decode to JSON before reuse - tool arguments must still be sent as JSON.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to the upstream tool as a native JSON object. Build arguments from the tool's compact signature ('sig') in retrieve_tools results — '*' marks required parameters, '~' marks a lossy signature (call describe_tool for the full JSON Schema before calling). Example: {\"path\": \"src/index.ts\", \"limit\": 20}. This is the preferred parameter — it eliminates JSON escaping overhead. Use 'args_json' only if your client cannot produce nested JSON objects.", + "properties": {}, + "type": "object" + }, + "args_json": { + "description": "Legacy: arguments as a pre-serialized JSON string. Prefer the 'args' parameter instead — it accepts a native JSON object and eliminates escaping overhead. If both are provided, 'args_json' wins for backward compatibility.", + "type": "string" + }, + "intent_data_sensitivity": { + "description": "Classify data being deleted: public, internal, private, or unknown. Important for tracking destructive operations on sensitive data.", + "type": "string" + }, + "intent_reason": { + "description": "Why is this deletion needed? Provide justification like 'User confirmed cleanup' or 'Removing obsolete data'.", + "type": "string" + }, + "name": { + "description": "Tool name in format 'server:tool' (e.g., 'github:delete_repo'). CRITICAL: You MUST use exact names from retrieve_tools results - do NOT guess or invent server names. Unknown servers will fail.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "call_tool_destructive" + }, + "call_tool_read": { + "annotations": { + "title": "Call Tool (Read)", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a READ-ONLY tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: search, query, list, get, fetch, find, check, view, read, show, describe, lookup, retrieve, browse, explore, discover, scan, inspect, analyze, examine, validate, verify. Examples: search_files, get_user, list_repositories, query_database, find_issues, check_status. This is the DEFAULT choice when unsure - most tools are read-only. Result blocks may be prefixed by the marker line: [mcpproxy:toon/v1] TOON-encoded JSON (toon-format.org); decode to JSON before reuse - tool arguments must still be sent as JSON.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to the upstream tool as a native JSON object. Build arguments from the tool's compact signature ('sig') in retrieve_tools results — '*' marks required parameters, '~' marks a lossy signature (call describe_tool for the full JSON Schema before calling). Example: {\"path\": \"src/index.ts\", \"limit\": 20}. This is the preferred parameter — it eliminates JSON escaping overhead. Use 'args_json' only if your client cannot produce nested JSON objects.", + "properties": {}, + "type": "object" + }, + "args_json": { + "description": "Legacy: arguments as a pre-serialized JSON string. Prefer the 'args' parameter instead — it accepts a native JSON object and eliminates escaping overhead. If both are provided, 'args_json' wins for backward compatibility.", + "type": "string" + }, + "intent_data_sensitivity": { + "description": "Classify data being accessed: public, internal, private, or unknown. Helps track sensitive data access patterns.", + "type": "string" + }, + "intent_reason": { + "description": "Why is this tool being called? Provide context like 'User asked to check status' or 'Gathering data for report'.", + "type": "string" + }, + "name": { + "description": "Tool name in format 'server:tool' (e.g., 'github:get_user'). CRITICAL: You MUST use exact names from retrieve_tools results - do NOT guess or invent server names. Unknown servers will fail.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "call_tool_read" + }, + "call_tool_write": { + "annotations": { + "title": "Call Tool (Write)", + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "Execute a STATE-MODIFYING tool. WORKFLOW: 1) Call retrieve_tools first to find tools, 2) Use the exact 'name' field from results, 3) Build args from the 'sig' signature ('*'=required; if lossy '~', call describe_tool first). DECISION RULE: Use this when the tool name contains: create, update, modify, add, set, send, edit, change, write, post, put, patch, insert, upload, submit, assign, configure, enable, register, subscribe, publish, move, copy, rename, merge. Examples: create_issue, update_file, send_message, add_comment, set_status, edit_page. Use only when explicitly modifying state. Result blocks may be prefixed by the marker line: [mcpproxy:toon/v1] TOON-encoded JSON (toon-format.org); decode to JSON before reuse - tool arguments must still be sent as JSON.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to the upstream tool as a native JSON object. Build arguments from the tool's compact signature ('sig') in retrieve_tools results — '*' marks required parameters, '~' marks a lossy signature (call describe_tool for the full JSON Schema before calling). Example: {\"path\": \"src/index.ts\", \"limit\": 20}. This is the preferred parameter — it eliminates JSON escaping overhead. Use 'args_json' only if your client cannot produce nested JSON objects.", + "properties": {}, + "type": "object" + }, + "args_json": { + "description": "Legacy: arguments as a pre-serialized JSON string. Prefer the 'args' parameter instead — it accepts a native JSON object and eliminates escaping overhead. If both are provided, 'args_json' wins for backward compatibility.", + "type": "string" + }, + "intent_data_sensitivity": { + "description": "Classify data being modified: public, internal, private, or unknown. Helps track sensitive data changes.", + "type": "string" + }, + "intent_reason": { + "description": "Why is this modification needed? Provide context like 'User requested update' or 'Fixing reported issue'.", + "type": "string" + }, + "name": { + "description": "Tool name in format 'server:tool' (e.g., 'github:create_issue'). CRITICAL: You MUST use exact names from retrieve_tools results - do NOT guess or invent server names. Unknown servers will fail.", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "call_tool_write" + }, + "code_execution": { + "annotations": { + "title": "Code Execution", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "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 (\u003e2 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**Stored scripts**: Instead of `code`, pass `script: \"\u003cname\u003e\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. Naming a script that does not exist returns the available names, which is how you discover what is stored.\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.", + "inputSchema": { + "properties": { + "code": { + "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, `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()})`", + "type": "string" + }, + "input": { + "description": "Input data accessible as global `input` variable in code (default: {})", + "properties": {}, + "type": "object" + }, + "language": { + "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'.", + "enum": [ + "javascript", + "typescript" + ], + "type": "string" + }, + "options": { + "description": "Execution options: timeout_ms (1-600000, default: 120000), max_tool_calls (\u003e= 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}).", + "properties": {}, + "type": "object" + }, + "script": { + "description": "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `\u003cname\u003e.js` / `\u003cname\u003e.ts` files in the `scripts/` directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. DISCOVERY: calling with a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total), so the current set can always be recovered from a single failed call. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code.", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "code_execution" + }, + "describe_tool": { + "annotations": { + "title": "Describe Tool", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Return full JSON Schema + long description for listed tools. Use when a signature is marked lossy ('~') or you need the exact schema before calling. With check:true it returns one availability verdict per id, not schemas ('ready', or a reason code with retryable/action), to gate a plan before its first call.", + "inputSchema": { + "properties": { + "check": { + "description": "Check availability only, no schemas (default false).", + "type": "boolean" + }, + "filters": { + "description": "check:true only. Annotation filters.", + "properties": { + "exclude_destructive": { + "type": "boolean" + }, + "exclude_open_world": { + "type": "boolean" + }, + "read_only_only": { + "type": "boolean" + } + }, + "type": "object" + }, + "tool_ids": { + "description": "Tool ids as listed: '\u003cserver\u003e:\u003ctool\u003e' or '\u003cserver\u003e__\u003ctool\u003e'. Max 5, or 50 with check:true.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "tool_ids" + ], + "type": "object" + }, + "name": "describe_tool" + }, + "list_registries": { + "annotations": { + "title": "List Registries", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "📋 List all available MCP registries. Use this FIRST to discover which registries you can search with the 'search_servers' tool. Each registry contains different collections of MCP servers that can be added as upstreams.", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object" + }, + "name": "list_registries" + }, + "quarantine_security": { + "annotations": { + "title": "Quarantine Security", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Security quarantine management AND TPA scanning for MCP servers and tools. Review and manage quarantined servers and tools to prevent Tool Poisoning Attacks (TPAs), and scan a server for them: 'scan_server' runs the always-on offline baseline scan (in-process, no Docker required) and 'get_scan_report' returns the latest verdict and findings. Every listing/inspection response also carries a one-line scan status, so an unscanned server is visible as unscanned. Supports server-level quarantine and tool-level approval for individual tool description/schema changes. NOTE: Unquarantining servers is only available through manual config editing or system tray UI for security.", + "inputSchema": { + "properties": { + "name": { + "description": "Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, approve_prompt, approve_all_prompts, scan_server, get_scan_report)", + "type": "string" + }, + "operation": { + "description": "Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool, inspect_prompts, approve_prompt, approve_all_prompts, scan_server, get_scan_report. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled. The prompt operations (spec 100) manage aggregated upstream prompts held by the metadata rug-pull baseline: a prompt whose advertised metadata changed since approval is withheld from prompts/list until approved. 'scan_server' starts the offline TPA baseline scan for one server (no Docker needed) and returns the verdict once it settles, or a job id to poll; 'get_scan_report' returns that server's latest verdict, counts and findings.", + "enum": [ + "list_quarantined", + "inspect_quarantined", + "quarantine_server", + "inspect_tools", + "approve_tool", + "approve_all_tools", + "block_tool", + "block_all_tools", + "enable_tool", + "disable_tool", + "inspect_prompts", + "approve_prompt", + "approve_all_prompts", + "scan_server", + "get_scan_report" + ], + "type": "string" + }, + "prompt_name": { + "description": "Prompt name (required for approve_prompt; spec 100)", + "type": "string" + }, + "tool_name": { + "description": "Tool name (required for approve_tool and block_tool operations)", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + "name": "quarantine_security" + }, + "read_cache": { + "annotations": { + "title": "Read Cache", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Retrieve paginated data when mcpproxy indicates a tool response was truncated. Use the cache key provided in truncation messages.", + "inputSchema": { + "properties": { + "key": { + "description": "Cache key provided by mcpproxy when a response was truncated.", + "type": "string" + }, + "limit": { + "description": "Maximum number of records to return per page (default: 50, max: 1000)", + "type": "number" + }, + "offset": { + "description": "Starting record offset for pagination (default: 0)", + "type": "number" + } + }, + "required": [ + "key" + ], + "type": "object" + }, + "name": "read_cache" + }, + "retrieve_tools": { + "annotations": { + "title": "Retrieve Tools", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Search and discover available upstream tools using BM25 full-text search. WORKFLOW: 1) Call this tool first to find relevant tools, 2) Check the 'call_with' field in results to determine which variant to use, 3) Call the tool using call_tool_read, call_tool_write, or call_tool_destructive. Results include 'annotations' (tool behavior hints like destructiveHint), 'call_with' recommendation, and a structured `session_risk` object (level, lethal_trifecta, has_open_world_tools, has_destructive_tools, has_write_tools). Compact mode returns one-line signatures ('sig': '*'=required, '~'=lossy) with first-sentence 'desc'; call describe_tool for full schemas. Use natural language to describe what you want to accomplish. ANNOTATION FILTERS: read_only_only, exclude_destructive and exclude_open_world self-restrict discovery. When they withhold tools that matched your query, the response carries a 'filter_diagnostics' block with per-filter counts (split into missing upstream annotations vs. explicitly unsafe ones) and one suggestion; it is absent when nothing was withheld. Filter diagnostics describe this call's candidate window, not the whole catalog.", + "inputSchema": { + "properties": { + "debug": { + "description": "Enable debug mode with detailed scoring and ranking explanations (default: false)", + "type": "boolean" + }, + "detail": { + "description": "Per-call response serialization override: 'compact' returns one-line signatures (sig/desc/lossy) instead of full schemas; 'full' returns complete inputSchema entries. Unset: the server's configured tool_response_mode applies.", + "enum": [ + "compact", + "full" + ], + "type": "string" + }, + "exclude_destructive": { + "description": "Exclude tools with destructiveHint=true or unset (MCP default is destructive). Use to avoid destructive operations.", + "type": "boolean" + }, + "exclude_open_world": { + "description": "Exclude tools with openWorldHint=true or unset (MCP default is open-world). Use to restrict to local/sandboxed tools.", + "type": "boolean" + }, + "explain_tool": { + "description": "When debug=true, explain why a specific tool was ranked low (format: 'server:tool')", + "type": "string" + }, + "include_session_risk_warning": { + "description": "Include the prose 'warning' string in session_risk when the lethal trifecta is detected (default: false; structured fields are always returned). Server-side default can be flipped via the 'tool_response_session_risk_warning' config flag.", + "type": "boolean" + }, + "include_stats": { + "description": "Include usage statistics for returned tools (default: false)", + "type": "boolean" + }, + "limit": { + "description": "Maximum number of tools to return (default: configured tools_limit, max: 100)", + "type": "number" + }, + "query": { + "description": "Natural language description of what you want to accomplish. Be specific (e.g., 'create a new GitHub repository', 'get weather for London').", + "type": "string" + }, + "read_only_only": { + "description": "Only return tools with readOnlyHint=true. Use to self-restrict to safe read operations.", + "type": "boolean" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "retrieve_tools" + }, + "search_servers": { + "annotations": { + "title": "Search Servers", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true + }, + "description": "🔍 Discover MCP servers from known registries with repository type detection. Search and filter servers from embedded registry list to find new MCP servers that can be added as upstreams. Features npm/PyPI package detection for enhanced install commands. WORKFLOW: 1) Call 'list_registries' first to see available registries, 2) Use this tool with a registry ID to search servers. Results include server URLs and repository information ready for direct use with upstream_servers add command.", + "inputSchema": { + "properties": { + "limit": { + "description": "Maximum number of results to return (default: 10, max: 50)", + "type": "number" + }, + "registry": { + "description": "Registry ID or name to search (e.g., 'smithery', 'mcprun', 'pulse'). Use 'list_registries' tool first to see available registries.", + "type": "string" + }, + "search": { + "description": "Search term to filter servers by name or description (case-insensitive)", + "type": "string" + }, + "tag": { + "description": "Filter servers by tag/category (if supported by registry)", + "type": "string" + } + }, + "required": [ + "registry" + ], + "type": "object" + }, + "name": "search_servers" + }, + "set_profile": { + "annotations": { + "title": "Set Profile", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Switch the active profile for THIS session. A profile scopes tool discovery (retrieve_tools) and tool calls to a named subset of upstream servers — useful to focus an agent on one task domain (e.g. 'research', 'deploy'). The selection persists for the lifetime of the current MCP session and applies to subsequent retrieve_tools / call_tool_* / code_execution calls on the base /mcp endpoint without re-indexing. Pass an empty string to clear the selection and go back to all servers. Note: an explicit /mcp/p/\u003cslug\u003e URL still overrides the session profile for that request, and a profile-pinned agent token cannot switch away from its pinned profile.", + "inputSchema": { + "properties": { + "profile": { + "description": "Profile slug to activate for this session (e.g. 'research'). Pass \"\" (empty) to clear the active profile and return to all servers.", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "set_profile" + }, + "upstream_servers": { + "annotations": { + "title": "Upstream Servers", + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Manage upstream MCP servers - add, remove, update, and list servers. Includes Docker isolation configuration and connection status monitoring. SECURITY: Newly added servers are automatically quarantined to prevent Tool Poisoning Attacks (TPAs). Use 'quarantine_security' tool to review and manage quarantined servers. NOTE: Unquarantining servers is only available through manual config editing or system tray UI for security.\n\nDocker Isolation: Use 'isolation_json' parameter to configure per-server Docker images, CPU/memory limits, and network isolation. Example: {\"enabled\": true, \"image\": \"node:20\", \"network_mode\": \"bridge\"}.\n\nSMART PATCHING (update/patch): Uses deep merge - only specify fields you want to change. Omitted fields are PRESERVED, not removed. Examples:\n- Enable server: {\"operation\": \"patch\", \"name\": \"my-server\", \"enabled\": true} - only enabled changes\n- Enable isolation: {\"operation\": \"patch\", \"name\": \"my-server\", \"isolation_json\": \"{\\\"enabled\\\": true}\"} - enables isolation with defaults\n- Update image: {\"operation\": \"patch\", \"name\": \"my-server\", \"isolation_json\": \"{\\\"image\\\": \\\"python:3.12\\\"}\"} - other isolation fields preserved\n- Add env var: env_json merges with existing vars\n- Replace args: args_json replaces entirely (arrays not merged)\n- Remove field: use 'null' (e.g., isolation_json: \"null\" removes isolation)\n\nREDACTION (update/patch): the returned 'changes' diff keeps every field PATH exact, but MASKS values under secret-bearing keys (env vars, headers, oauth secrets, credential-shaped argv tokens) in both this response and the activity log. Non-secret values round-trip unchanged; do not read a masked value back as what was stored.", + "inputSchema": { + "properties": { + "args_json": { + "description": "Command arguments for stdio servers as a JSON array of strings (e.g., '[\"mcp-server-sqlite\", \"--db-path\", \"/path/to/db\"]'). For update/patch: REPLACES all existing args (arrays are not merged).", + "type": "string" + }, + "command": { + "description": "Command to run for stdio servers (e.g., 'uvx', 'python')", + "type": "string" + }, + "enabled": { + "description": "Whether server should be enabled (default: true)", + "type": "boolean" + }, + "env_json": { + "description": "Environment variables for stdio servers as JSON object (e.g., '{\"API_KEY\": \"value\"}'). For update/patch: MERGES with existing vars (new keys added, existing keys updated).", + "type": "string" + }, + "expose_prompts": { + "description": "Per-server prompt-aggregation override (F9): true = include this server's MCP prompts in mcpproxy's aggregated prompts/list; false = exclude them regardless of capability. Omit to leave unchanged (patch) / inherit the default (aggregate if advertised). Only meaningful when aggregate_upstream_prompts is enabled globally. Used with add/update/patch.", + "type": "boolean" + }, + "headers_json": { + "description": "HTTP headers for authentication as JSON object (e.g., '{\"Authorization\": \"Bearer token\"}'). For update/patch: MERGES with existing headers (new keys added, existing keys updated).", + "type": "string" + }, + "id": { + "description": "Server id within the registry - required for add_from_registry.", + "type": "string" + }, + "init_timeout": { + "description": "Per-server MCP `initialize` handshake deadline as a duration string (e.g. '120s', '3m'). Raise this for upstreams that do legitimate first-run warmup (cache/index build) before responding to `initialize`, so they are not killed mid-startup. Unset → global default (30s). Bounds: 1s–30m. Used with add/update/patch.", + "type": "string" + }, + "isolation_json": { + "description": "Docker isolation config as JSON object. MERGES with existing settings - only provided fields change. Use 'null' to remove isolation entirely. Example: '{\"image\": \"python:3.12\"}' updates only the image.", + "type": "string" + }, + "lines": { + "description": "Number of lines to tail from server log (default: 50, max: 500) - used with tail_log operation", + "type": "number" + }, + "name": { + "description": "Server name (required for add/remove/update/patch/tail_log operations; optional name override for add_from_registry)", + "type": "string" + }, + "oauth_json": { + "description": "OAuth config as JSON object. MERGES with existing settings. Use 'null' to remove OAuth entirely. Fields: client_id, client_secret, scopes (array - replaces).", + "type": "string" + }, + "operation": { + "description": "Operation: list, add, remove, update, patch, tail_log, add_from_registry, enable, disable, restart, refresh. 'update' and 'patch' use smart merge - only specified fields change, others preserved. 'add_from_registry' adds an upstream from a registry reference (registry+id) so you need not hand-construct command/args/url - the server re-derives the runnable config and quarantines it. 'refresh' re-discovers and re-indexes a server's tools without changing any security state - use it to make just-approved tools searchable immediately. For quarantine operations, use the 'quarantine_security' tool.", + "enum": [ + "list", + "add", + "remove", + "update", + "patch", + "tail_log", + "add_from_registry", + "enable", + "disable", + "restart", + "refresh" + ], + "type": "string" + }, + "protocol": { + "description": "Transport protocol: stdio, http, sse, streamable-http, auto (default: auto-detect)", + "enum": [ + "stdio", + "http", + "sse", + "streamable-http", + "auto" + ], + "type": "string" + }, + "registry": { + "description": "Registry id to add from (e.g. 'pulse') - required for add_from_registry. Use the 'list_registries'/'search_servers' tools to discover registries and server ids.", + "type": "string" + }, + "trust_mode": { + "description": "Per-server trust tier governing new-server admission AND tool-change approval (spec 086): 'auto' = approve without scanning; 'scan' = auto-approve only when the fast offline TPA scan is green, else hold for review; 'manual' = human reviews every change. Empty → manual (secure default). Used with add/update/patch.", + "enum": [ + "auto", + "scan", + "manual" + ], + "type": "string" + }, + "url": { + "description": "Server URL for HTTP/SSE servers (e.g., 'http://localhost:3001')", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + }, + "name": "upstream_servers" + } +} diff --git a/internal/server/testdata/toolslist_goldens/retrieve_tools_mode.json b/internal/server/testdata/toolslist_goldens/retrieve_tools_mode.json index a3911775e..846578bd1 100644 --- a/internal/server/testdata/toolslist_goldens/retrieve_tools_mode.json +++ b/internal/server/testdata/toolslist_goldens/retrieve_tools_mode.json @@ -127,7 +127,7 @@ "idempotentHint": false, "openWorldHint": true }, - "description": "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 (\u003e2 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**Stored scripts**: Instead of `code`, pass `script: \"\u003cname\u003e\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. Naming a script that does not exist returns the available names, which is how you discover what is stored.\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.", + "description": "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 (\u003e2 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**Stored scripts**: Instead of `code`, pass `script: \"\u003cname\u003e\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. The stored-script listing is administrator-only (`mcpproxy code scripts list`, or the not-found error under the admin API key); an agent-token caller must already know the script name — a name that does not exist is refused without naming what is stored.\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.", "inputSchema": { "properties": { "code": { @@ -153,7 +153,7 @@ "type": "object" }, "script": { - "description": "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `\u003cname\u003e.js` / `\u003cname\u003e.ts` files in the `scripts/` directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. DISCOVERY: calling with a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total), so the current set can always be recovered from a single failed call. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code.", + "description": "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `\u003cname\u003e.js` / `\u003cname\u003e.ts` files in the `scripts/` directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. ENUMERATION IS ADMINISTRATOR-ONLY: for an administrator (the admin API key, the tray, an in-process caller) a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total); an agent-token caller must already know the script name — its not-found error names neither the stored scripts nor how many there are. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code.", "type": "string" } }, diff --git a/internal/server/toolslist_snapshot_test.go b/internal/server/toolslist_snapshot_test.go index e4266dbe1..eb8c84d92 100644 --- a/internal/server/toolslist_snapshot_test.go +++ b/internal/server/toolslist_snapshot_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "sort" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -321,3 +322,135 @@ func reportToolsListDiff(t *testing.T, surface string, want, got []byte) { "surface %s: tool %q schema changed (FR-015)", surface, name) } } + +// --------------------------------------------------------------------------- +// Spec 105 FR-012 (PR H0, FR01x-G2): the ONE narrow golden exception. +// +// The code_execution definition told every caller to discover stored scripts +// by requesting a name that does not exist. Under FR-012 that enumeration is +// administrator-only (an agent-token caller gets a non-disclosing refusal, +// see mcp_code_scripts_test.go), so the published text has to say so — and +// the goldens that pin the text move by exactly those two strings and +// nothing else. testdata/toolslist_goldens/pre105/ is the FROZEN copy of the +// three goldens as they stood before this spec (never regenerated); the live +// goldens are compared against it entry by entry and field by field. +// --------------------------------------------------------------------------- + +const ( + // toolsListPre105Dir holds the frozen pre-Spec-105 capture of the three + // surfaces, the baseline the FR-012 narrow-diff assertion measures against. + toolsListPre105Dir = "pre105" + + // spec105CodeExecutionTool is the only entry allowed to differ from the + // pre-105 baseline, and only in the two description strings below. + spec105CodeExecutionTool = "code_execution" +) + +// spec105EnumerationPhrases are the pre-105 fragments that advertised +// discovery-by-failed-call. Neither may survive in the live strings. +var spec105EnumerationPhrases = []string{ + "returns the available names, which is how you discover what is stored", + "DISCOVERY: calling with a name that does not exist returns an error listing the available script names", + "so the current set can always be recovered from a single failed call", +} + +// TestCodeExecutionDescriptions_EnumerationIsAdminOnly (T063) pins the +// reworded definition text and the narrow golden delta together: the live +// strings no longer teach enumeration by failed call and name the +// administrator-only rule, and the regenerated goldens differ from the frozen +// pre-105 capture in code_execution.description and +// code_execution.inputSchema.properties.script.description ONLY. +func TestCodeExecutionDescriptions_EnumerationIsAdminOnly(t *testing.T) { + t.Run("live strings", func(t *testing.T) { + for _, phrase := range spec105EnumerationPhrases { + assert.NotContains(t, codeExecutionToolDescription, phrase, + "code_execution.description must not advertise discovery by failed call (FR-012)") + assert.NotContains(t, codeExecutionScriptDescription, phrase, + "script.description must not advertise discovery by failed call (FR-012)") + } + for _, text := range []string{codeExecutionToolDescription, codeExecutionScriptDescription} { + lower := strings.ToLower(text) + assert.Contains(t, lower, "administrator", + "the definition must say enumeration is administrator-only (FR-012)") + assert.Contains(t, lower, "agent", + "the definition must tell agent-token callers they need to already know the name (FR-012)") + } + }) + + for _, surface := range toolsListGoldenSurfaces { + surface := surface + t.Run(surface, func(t *testing.T) { + before := decodeToolsListGolden(t, filepath.Join("testdata", toolsListGoldenDir, toolsListPre105Dir, surface+".json")) + after := decodeToolsListGolden(t, toolsListGoldenPath(surface)) + + // The tool SET is untouched: nothing added, nothing removed. + assert.Equal(t, sortedToolNames(before), sortedToolNames(after), + "surface %s: the FR-012 exception changes two strings, never the tool set", surface) + + // Every other entry is byte-equal to the frozen capture. + for name, pre := range before { + if name == spec105CodeExecutionTool { + continue + } + assert.True(t, bytes.Equal(pre, after[name]), + "surface %s: tool %q must be byte-identical to the pre-105 golden (FR-012: only code_execution may move)", surface, name) + } + + preTool, ok := before[spec105CodeExecutionTool] + require.True(t, ok, "surface %s: frozen baseline carries code_execution", surface) + postTool, ok := after[spec105CodeExecutionTool] + require.True(t, ok, "surface %s: live golden carries code_execution", surface) + + var preM, postM map[string]interface{} + require.NoError(t, json.Unmarshal(preTool, &preM)) + require.NoError(t, json.Unmarshal(postTool, &postM)) + + preDesc, _ := preM["description"].(string) + postDesc, _ := postM["description"].(string) + preScript := codeExecScriptDescriptionOf(t, preM) + postScript := codeExecScriptDescriptionOf(t, postM) + + // Both strings MOVED (a regenerated golden that still carries the + // pre-105 wording is the description lying about the runtime), and + // the live golden carries exactly the live constants. + assert.NotEqual(t, preDesc, postDesc, + "surface %s: code_execution.description must be regenerated with the FR-012 wording", surface) + assert.NotEqual(t, preScript, postScript, + "surface %s: script.description must be regenerated with the FR-012 wording", surface) + assert.Equal(t, codeExecutionToolDescription, postDesc, "surface %s: golden description == live constant", surface) + assert.Equal(t, codeExecutionScriptDescription, postScript, "surface %s: golden script.description == live constant", surface) + for _, phrase := range spec105EnumerationPhrases { + assert.NotContains(t, postDesc, phrase, "surface %s: regenerated golden still advertises enumeration", surface) + assert.NotContains(t, postScript, phrase, "surface %s: regenerated golden still advertises enumeration", surface) + } + + // And NOTHING else moved: put the two pre-105 strings back into the + // live entry and it must deep-equal the frozen one. + postM["description"] = preDesc + setCodeExecScriptDescription(t, postM, preScript) + assert.Equal(t, preM, postM, + "surface %s: code_execution may differ from the pre-105 golden in description and script.description only (FR-012)", surface) + }) + } +} + +// codeExecScriptDescriptionOf reads inputSchema.properties.script.description +// from a decoded tool entry. +func codeExecScriptDescriptionOf(t *testing.T, tool map[string]interface{}) string { + t.Helper() + schema, _ := tool["inputSchema"].(map[string]interface{}) + props, _ := schema["properties"].(map[string]interface{}) + script, _ := props["script"].(map[string]interface{}) + require.NotNil(t, script, "code_execution must expose the `script` parameter") + desc, _ := script["description"].(string) + return desc +} + +func setCodeExecScriptDescription(t *testing.T, tool map[string]interface{}, desc string) { + t.Helper() + schema, _ := tool["inputSchema"].(map[string]interface{}) + props, _ := schema["properties"].(map[string]interface{}) + script, _ := props["script"].(map[string]interface{}) + require.NotNil(t, script) + script["description"] = desc +} diff --git a/oas/docs.go b/oas/docs.go index 4e816dfcb..3b7ba5e60 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -9,7 +9,7 @@ const docTemplate = `{ "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":"ActivityMaxSizeMB caps the total activity-log size in MB before the\noldest records are pruned. Omit the key for the 256MB default; set it to\n0 to disable the size cap.","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"aggregate_upstream_prompts":{"description":"AggregateUpstreamPrompts, when true, aggregates every connected upstream\nserver's advertised MCP prompts into mcpproxy's own prompts/list\n(exposed as \"\u003cserver\u003e__\u003cprompt\u003e\"). OFF by default: users are safe by\ndefault and opt in deliberately. EnablePrompts still governs the built-in\nprompts + the prompts capability; this flag gates ONLY the upstream\naggregation performed by RefreshPrompts. Hot-reloadable.","type":"boolean"},"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"},"direct_tool_response_mode":{"description":"DirectToolResponseMode selects the serialization of the DIRECT\nenumeration surface (Spec 102). Valid values: \"\" (= full), \"full\"\n(default: today's schema-bearing entries), \"deferred\" (description +\ncompact signature, with a minimal permissive input schema; upstream\ninputSchema and outputSchema are stripped and recovered on demand via\ndescribe_tool).\n\nDeliberately NOT an extension of tool_response_mode: reusing that axis\nwould silently change /mcp/all output for every deployment already\nrunning compact, which FR-015 forbids. Serialization-only — it never\nchanges WHICH tools are listed, only how (FR-008). Hot-reloadable.","type":"string"},"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":"MaxResultSizeChars is advertised on every tool as\n` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; it raises Claude Code's\ninline-response ceiling from 50k to up to 500k chars. Omit the key for\nthe 500000 default; set it to 0 to disable the annotation.","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_call_max_records_per_server":{"description":"Calls retained per server (default: 1000)","type":"integer"},"tool_call_max_response_size":{"description":"Bounds for the per-server tool-call history behind GET /api/v1/tool-calls\n(#1176). It is a recent-debugging window, not an audit log — the activity\nlog is the durable record — and it kept every upstream response whole,\nper server, forever. A non-positive value means \"use the default\", not\n\"disable\": this store must never be unbounded again, so there is\ndeliberately no off switch.","type":"integer"},"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},"trusted_proxies":{"description":"TrustedProxies lists the CIDRs or IP addresses whose X-Forwarded-For /\nX-Real-IP / X-Forwarded-Proto / X-Forwarded-Host headers are believed\n(Spec 107 FR-027). Empty (default) trusts nobody. Edition-neutral, live\n(hot-reloadable). Env override: MCPPROXY_TRUSTED_PROXIES (comma-separated).\nThe one reader is ForwardedHeaders; validation is validateTrustedProxies.","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":{"auto_baseline_scan":{"description":"AutoBaselineScan is the kill-switch for the AUTOMATIC, informational\nPass-1 baseline scan: the free in-process TPA scan mcpproxy runs for every\nnewly admitted server (any trust mode) and, once per installation, over\npre-existing servers that have never been scanned.\n\nInformational ONLY: the resulting verdict populates the security badge and\nthe scan summary, and NEVER gates quarantine or approval. The\ntrust_mode:\"scan\" admission gate is a separate path and is unaffected by\nthis flag.\n\nDefault (nil) is ENABLED. Set to false to suppress every automatic scan\n(manual scans keep working). Env override: MCPPROXY_AUTO_BASELINE_SCAN,\nwhich wins over this field on every path.","type":"boolean"},"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"},"expose_prompts":{"description":"ExposePrompts overrides whether this server's advertised MCP prompts are\naggregated into mcpproxy's prompts/list. nil (default) inherits the\ndefault-aggregate behavior (included if the server advertises\nCapabilities.Prompts); false excludes it regardless of capability.","type":"boolean"},"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.\nOmit the key for the 0.1 default; set it to 0 to sample nothing.","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"},"parent_id":{"description":"Correlation id of the parent call (the code_execution whose sandbox issued this sub-call)","type":"string"},"request_bytes":{"description":"Byte sizes measured pre-truncation, mirroring storage.ActivityRecord\n(Spec 069 A1). They are the only cost signal a bodies-off export carries:\nwith payloads suppressed there is no text left to measure, so a consumer\naccounting for a record it cannot read has nothing else to go on. They are\nbyte LENGTHS, not token counts — the basis for an explicit estimate, never\na measured figure (spec 103, contracts/replay-input.md).\n\nZero means UNKNOWN, not free: legacy records predate the measurement and\ncode-execution sub-calls record both as zero. Hence omitempty — an absent\nkey tells a consumer to fall to exclusion accounting, whereas a present\nzero would read as a costless call and silently understate the workload.","type":"integer"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_bytes":{"description":"Raw upstream response size in bytes before truncation","type":"integer"},"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"},"call_count":{"description":"CallCount is how many of those records are CALLS THE USER MADE, as\ndefined once in storage.CountsAsCall and shared with the usage aggregate\nbehind the Usage tab (audit finding F1, #1046). TotalCount answers \"how\nmany rows does the Activity Log have\"; CallCount answers \"how many calls\nwere there\". They are different questions — quarantine auto-approvals,\nsystem start, security scans and management chatter are events, not calls\n— and printing either one under the other's label is how the same instance\ncame to report 51 calls on one screen and 19 on another.","type":"integer"},"call_error_count":{"description":"CallErrorCount is the failures within CallCount, so an error RATE computed\nfrom this response has one denominator. It is not ErrorCount: a policy\nblock is a failed call but carries status \"blocked\", and a shed call is an\nerror in neither sense because it never ran.","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"other_count":{"description":"OtherCount is every record whose status is outside the four-value\nvocabulary above, so that\n\n\tsuccess + error + blocked + rejected + other == total\n\nholds by construction. The status field is a CLOSED vocabulary for tool\ncalls, but the activity log is wider than tool calls: a quarantine change\nstores its ACTION there (\"approved\", \"auto_approved\"), a policy decision\nstores its DECISION (\"allow\"). Those rows were counted in the total and in\nnone of the four buckets, so the Activity Log's own status tiles summed to\nless than the denominator printed beside them — 15+4+0+0 under a \"42\"\n(audit finding F2, #1046). The residual now has a name and a tile.","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\", \"edit_url\", 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":{"description":"Enabled is the EFFECTIVE isolation state for this server: whether its\nprocess is actually CONFINED, after the global setting, the per-server\noverride, the structural gates and the host's capabilities. It is NOT the\nraw per-server override — read EnabledOverride for that (GH #1142).\n\nREAD-ONLY. The write surfaces reject an ` + "`" + `enabled` + "`" + ` key precisely because\nit is derived: echoing it back would convert \"inherits the global\nsetting\" into a permanent explicit override. Write EnabledOverride.\n\nIt stays a non-pointer bool that is always present on the wire: the macOS\ntray decodes it as a non-optional Swift Bool, so omitting or nulling the\nkey would fail Codable for the whole server payload. Older clients that\nread this field now simply get a true answer.","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the RAW per-server ` + "`" + `isolation.enabled` + "`" + ` override, as\npersisted. Absent means \"inherit the global setting\" — which is a\ndistinct state from an explicit false, and the distinction the reporting\nbug used to destroy.","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"mode_override":{"description":"ModeOverride is the RAW per-server ` + "`" + `isolation.mode` + "`" + ` override\n(\"docker\" | \"sandbox\" | \"none\"). Absent means \"inherit\".","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.IsolationEffective":{"description":"IsolationEffective exposes the resolved isolation state (and the rule\nthat decided it) so clients can distinguish \"inherits global\" from an\nexplicit per-server choice. Read-only; never consumed on PATCH.","properties":{"global_mode":{"description":"GlobalMode is what \"inherit\" resolves to right now.","type":"string"},"inherited":{"description":"Inherited is true when the server sets neither ` + "`" + `isolation.enabled` + "`" + ` nor\n` + "`" + `isolation.mode` + "`" + `, so its state tracks the global setting.","type":"boolean"},"isolated":{"description":"Isolated reports whether the process is actually CONFINED. It is NOT\nsimply Mode != \"none\": \"sandbox\" on a host that cannot enforce Landlock\n(any non-Linux OS, or a kernel without the LSM) runs the server\nunconfined, and Source then says \"sandbox-unavailable\" (GH #1142).","type":"boolean"},"mode":{"description":"Mode is the effective isolation mode: \"docker\" | \"sandbox\" | \"none\" —\nexactly what the spawn path branches on.","type":"string"},"source":{"description":"Source names the deciding rule: \"global\", \"server-mode\",\n\"server-opt-out\", \"server-opt-in-ignored\", \"not-stdio\",\n\"already-docker\", \"sandbox-unavailable\" or \"unsupported-mode\".\nTreat an unrecognized value as \"global\".","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.PreflightPolicy":{"properties":{"exclude_destructive":{"type":"boolean"},"exclude_open_world":{"type":"boolean"},"read_only_only":{"type":"boolean"}},"type":"object"},"contracts.PreflightReason":{"type":"string","x-enum-varnames":["PreflightReasonServerInitializing","PreflightReasonServerUnhealthy","PreflightReasonServerDisabled","PreflightReasonServerQuarantined","PreflightReasonToolPendingApproval","PreflightReasonToolChanged","PreflightReasonToolBlockedByUser","PreflightReasonOAuthRequired","PreflightReasonHashMismatch","PreflightReasonServerNotInScope","PreflightReasonToolDeniedByConfig","PreflightReasonMissingAnnotation","PreflightReasonPolicyFiltered","PreflightReasonNotFound","PreflightReasonServerNotConfigured"]},"contracts.PreflightRequest":{"properties":{"policy":{"$ref":"#/components/schemas/contracts.PreflightPolicy"},"profile":{"description":"Profile evaluates under a named profile's server scope. Unknown: 400.","type":"string"},"tools":{"description":"Tools is 1..100 entries BEFORE dedup; duplicates are collapsed, and\nduplicate ids carrying different pins are a validation error.","items":{"$ref":"#/components/schemas/contracts.PreflightToolRef"},"type":"array","uniqueItems":false},"wait_ms":{"description":"WaitMS polls local state for up to this many milliseconds (cap 10000)\nwhile every failure is retryable-class.","type":"integer"}},"type":"object"},"contracts.PreflightResponse":{"properties":{"checked_at":{"type":"string"},"tools":{"description":"Tools are ordered by first occurrence of each unique id in the request.","items":{"$ref":"#/components/schemas/contracts.PreflightToolResult"},"type":"array","uniqueItems":false},"verdict":{"$ref":"#/components/schemas/contracts.PreflightVerdict"},"waited_ms":{"description":"WaitedMS is present when wait_ms was requested (0 when the wait\nsemaphore was exhausted and the request resolved immediately).","type":"integer"}},"type":"object"},"contracts.PreflightStatus":{"type":"string","x-enum-varnames":["PreflightStatusReady","PreflightStatusUnavailable"]},"contracts.PreflightToolRef":{"properties":{"id":{"description":"ID is a canonical \"\u003cserver\u003e:\u003ctool\u003e\" id. A malformed id is answered with a\nper-ID not_found carrying a format hint, never a request-level error.","type":"string"},"pin_hash":{"description":"PinHash is \"sha256/v{N}:{hex}\" — the schema version is embedded so a\nproxy-side hash-algorithm bump is distinguishable from upstream drift.","type":"string"}},"type":"object"},"contracts.PreflightToolResult":{"properties":{"action":{"type":"string"},"detail":{"type":"string"},"did_you_mean":{"description":"DidYouMean carries up to 3 nearest caller-visible ids on not_found. It\nnever crosses a scope boundary and never names a quarantined server's\ntools.","items":{"type":"string"},"type":"array","uniqueItems":false},"hash":{"description":"Hash is the tool's current pin (\"sha256/v{N}:{hex}\") — operator tier,\nready results only. Never disclosed to an agent token.","type":"string"},"id":{"type":"string"},"reason":{"$ref":"#/components/schemas/contracts.PreflightReason"},"remediation":{"type":"string"},"retryable":{"type":"boolean"},"status":{"$ref":"#/components/schemas/contracts.PreflightStatus"}},"type":"object"},"contracts.PreflightVerdict":{"type":"string","x-enum-varnames":["PreflightVerdictReady","PreflightVerdictDegradedRetryable","PreflightVerdictBlocked","PreflightVerdictUnknownIDs"]},"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"},"expose_prompts":{"description":"ExposePrompts mirrors config.ServerConfig.ExposePrompts (F9): the per-server\nprompt-aggregation override. Tri-state *bool — nil/omitted means \"inherit\ndefault aggregation\". Surfaced on GET so a caller that PATCHed the override\ncan read it back; PATCH/POST accept it via AddServerRequest.","type":"boolean"},"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"},"isolation_effective":{"$ref":"#/components/schemas/contracts.IsolationEffective"},"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"},"retry_stopped":{"description":"RetryStopped reports that automatic reconnection has been given up for\ngood because the failure is deterministic and unrecoverable — a missing\nbinary, an image without the interpreter, an unparseable config (GH\n#1145). It is NOT ordinary exponential backoff, which keeps retrying;\nnothing will happen until the user fixes the config or restarts the\nserver. RetryStoppedCode is the stable MCPX_* code that proved it and\nRetryStoppedReason the catalog message explaining how to fix it. All three\nare omitted for servers that are healthy or still retrying.","type":"boolean"},"retry_stopped_code":{"type":"string"},"retry_stopped_reason":{"type":"string"},"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"},"hash":{"description":"Hash is the tool's current stored hash rendered in the preflight pin\nformat \"sha256/v{N}:{hex}\" (Spec 098 FR-011), where N is the approval\nrecord's HashSchemaVersion. It is the authoring surface for\n` + "`" + `POST /api/v1/preflight` + "`" + ` pins and ` + "`" + `mcpproxy tools preflight --pin` + "`" + `:\ncopy the value straight into a pin.\n\nDisclosure is OPERATOR TIER ONLY — same rule as the preflight per-tool\nresult. The field is omitted for agent-token callers and for tools with\nno stored hash (no approval record yet, or a record written before\nhashes existed).","type":"string"},"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"},"arguments_truncated":{"description":"ArgumentsTruncated marks Arguments as a placeholder rather than the\narguments the tool was called with. Replaying such a record without\nsupplying arguments explicitly is refused.","type":"boolean"},"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"},"response_bytes":{"description":"Marshalled response size before truncation","type":"integer"},"response_truncated":{"description":"ResponseTruncated and ResponseBytes describe a STORAGE-side cut (#1176):\nthe caller received the response whole, and only the persisted copy was\nshortened to tool_call_max_response_size. When ResponseTruncated is true\nthe Response object carries {truncated, original_bytes, preview, note}\ninstead of the upstream result, and ResponseBytes is its size before the\ncut.","type":"boolean"},"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"},"behind_summary":{"description":"Spec 079 FR-002 — how far behind the running build is. All four are\nadditive (FR-021) and absent when the delta could not be resolved, in\nwhich case every surface renders its pre-delta wording.","type":"string"},"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"},"releases_behind":{"description":"Releases on the offered channel between the running and offered versions","type":"integer"},"releases_behind_saturated":{"description":"ReleasesBehind is a lower bound: the running build predates the scanned release window","type":"boolean"},"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"},"weeks_behind":{"description":"Whole weeks between the two releases' publish dates; 0 is a real value, absent means unknown","type":"integer"}},"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},"total_calls":{"description":"TotalCalls and TotalErrors are the headline counts for the window: the sum\nof the timeline this same response carries, so the tiles and the histogram\nunder them cannot disagree. They are NOT the sum of Tools — that list is\nlifetime-cumulative, upstream-only and truncated to top-N, and summing it\nclient-side is what made the Usage tab print a third number for the same\n24 hours (audit finding F1, #1046). The population is\nstorage.CountsAsCall, shared with ActivitySummaryResponse.CallCount.\n\nTwo bounds on how exactly this matches the Activity Log's own count.\nBoth are bounded and disclosed, unlike the population mismatch they\nreplace, which was unbounded and silent:\n\n - Window granularity is the timeline's: whole hour buckets, so the span\n is the requested window rounded up to a bucket edge.\n - This response is served from a snapshot behind a short read cache\n (observability.usage_cache_ttl, 5s by default) so the endpoint never\n scans the activity log per request, while the summary endpoint counts\n live. Calls that land inside that window appear on the Activity Log\n first. FreshnessMs and GeneratedAt say how old the figures are, and\n the Usage tab prints it (\"Updated 3s ago\").","type":"integer"},"total_errors":{"type":"integer"},"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_exceeds":{"type":"boolean"},"p50_ms":{"description":"P50Ms and P95Ms are read off a fixed latency histogram, so they are BUCKET\nBOUNDS, not measured durations: the true percentile is at or below the\nvalue, and a client must render it as a bound (\"≤ 5 ms\"). P50Exceeds /\nP95Exceeds flip that reading for the unbounded overflow bucket, where the\nvalue is the last bound and the truth is above it (\"\u003e 10 s\").","type":"integer"},"p95_exceeds":{"type":"boolean"},"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"},"expose_prompts":{"description":"ExposePrompts is the per-server override for prompt aggregation (F9):\nwhether this server's advertised MCP prompts are merged into mcpproxy's\nprompts/list. Tri-state *bool mirroring config.ServerConfig.ExposePrompts —\na nil pointer means \"leave unchanged\" on PATCH (and \"inherit the default\naggregate behavior\" on create); a present value (including false) is applied.","type":"boolean"},"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 (enabled,\nmode_override, image, network_mode, extra_args, working_dir). A nil\npointer means \"do not touch isolation config\". A present object is\napplied field-by-field ON TOP of the persisted overrides, so omitting a\nfield leaves it alone; clear an individual override by sending it\nexplicitly (` + "`" + `\"enabled\": null` + "`" + `, ` + "`" + `\"image\": \"\"` + "`" + `).","properties":{"enabled":{"description":"Enabled exists ONLY to detect and reject an echoed-back read. It is the\neffective state on the read surface and is never writable; see validate().","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the tri-state per-server override — the RAW value, the\nsame one reads return as ` + "`" + `enabled_override` + "`" + `. It has THREE meaningful wire\nstates, and collapsing them is what silently un-isolated servers\n(GH #1142):\n - absent → leave the persisted override untouched\n - null → clear the override, back to inheriting the global\n - true / false → set an explicit opt-in / opt-out","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"mode_override":{"description":"ModeOverride sets ` + "`" + `isolation.mode` + "`" + ` (\"docker\" | \"sandbox\" | \"none\").\nnil leaves the persisted value alone; an empty string clears it. An\nunrecognized value is rejected with a 400 rather than persisted.","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","preflight","prompt_get"],"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":"Filter by parent call id — returns the sub-calls one code_execution issued","in":"query","name":"parent_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 by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Filter by parent call id — exports the sub-calls one code_execution issued","in":"query","name":"parent_id","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/code/scripts":{"get":{"description":"List the stored scripts available to the code_execution tool. Scripts are ` + "`" + `\u003cname\u003e.js` + "`" + ` / ` + "`" + `\u003cname\u003e.ts` + "`" + ` files in the ` + "`" + `scripts/` + "`" + ` directory next to the active configuration file. Entries are advisory: ` + "`" + `ok` + "`" + ` scripts are invocable, ` + "`" + `ambiguous` + "`" + ` names have both extensions, and ` + "`" + `invalid` + "`" + ` ones report why (empty, oversized, unreadable, non-regular). Read-only — there is no write surface for stored scripts.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Stored scripts and the directory they were read from"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List stored code-execution scripts","tags":["code"]}},"/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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read the configuration document"},"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\nweb_ui_url carries the ?apikey= credential ONLY for an authenticated admin; a scoped agent token receives the bare URL\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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read onboarding state"},"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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read onboarding state"},"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/preflight":{"post":{"description":"Deterministic, side-effect-free availability check for a caller-supplied list of tool IDs (Spec 098). Performs zero upstream calls and mutates no runtime state. HTTP status reports whether the CHECK executed: a fully blocked set is still 200, with the availability verdict in the body. Every executed preflight writes an activity record before the response is returned.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.PreflightRequest"}}},"description":"Tool IDs (1-100 before dedup), optional profile, annotation policy filters and wait budget","required":true},"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":"Preflight verdict and per-tool results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Validation error (malformed, oversized, doubled or unknown-field body; empty or oversized tool list; conflicting duplicate pins; unknown profile; wait_ms out of range)"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Missing or invalid credentials"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Runtime unavailable, evaluator infrastructure read failure, or the activity record could not be persisted"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Preflight required tools","tags":["tools"]}},"/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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot change the active profile)"},"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.\nrouting_mode is what /mcp is actually serving; pending_routing_mode carries a\nrestart-pending value persisted on disk (empty when there is none).\ntool_response_mode and direct_tool_response_mode report the two serialization axes, resolved.","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. Isolation: ` + "`" + `isolation.enabled` + "`" + ` is READ-ONLY (it reports the effective state on reads) and is rejected with 400; set the per-server override via ` + "`" + `isolation.enabled_override` + "`" + ` (true | false | null to clear, omit to leave unchanged). An unrecognized ` + "`" + `isolation.mode_override` + "`" + ` is rejected with 400.","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. Isolation: ` + "`" + `isolation.enabled` + "`" + ` is READ-ONLY (it reports the effective state on reads) and is rejected with 400; set the per-server override via ` + "`" + `isolation.enabled_override` + "`" + ` (true | false | null to clear, omit to leave unchanged). An unrecognized ` + "`" + `isolation.mode_override` + "`" + ` is rejected with 400.","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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read MCP session history"},"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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read MCP session history"},"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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read deployment-wide 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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read the deployment telemetry 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"]}}}, + "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","preflight","prompt_get"],"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":"Filter by parent call id — returns the sub-calls one code_execution issued","in":"query","name":"parent_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 by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Filter by parent call id — exports the sub-calls one code_execution issued","in":"query","name":"parent_id","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/code/scripts":{"get":{"description":"List the stored scripts available to the code_execution tool. Scripts are ` + "`" + `\u003cname\u003e.js` + "`" + ` / ` + "`" + `\u003cname\u003e.ts` + "`" + ` files in the ` + "`" + `scripts/` + "`" + ` directory next to the active configuration file. Entries are advisory: ` + "`" + `ok` + "`" + ` scripts are invocable, ` + "`" + `ambiguous` + "`" + ` names have both extensions, and ` + "`" + `invalid` + "`" + ` ones report why (empty, oversized, unreadable, non-regular). Read-only — there is no write surface for stored scripts. Administrator-only (Spec 105 FR-012): an agent token, whatever its server scope, is refused with 403 — the listing is the enumeration the missing-script error withholds from a scoped caller.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Stored scripts and the directory they were read from"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot list stored scripts"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List stored code-execution scripts","tags":["code"]}},"/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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read the configuration document"},"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\nweb_ui_url carries the ?apikey= credential ONLY for an authenticated admin; a scoped agent token receives the bare URL\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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read onboarding state"},"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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read onboarding state"},"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/preflight":{"post":{"description":"Deterministic, side-effect-free availability check for a caller-supplied list of tool IDs (Spec 098). Performs zero upstream calls and mutates no runtime state. HTTP status reports whether the CHECK executed: a fully blocked set is still 200, with the availability verdict in the body. Every executed preflight writes an activity record before the response is returned.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.PreflightRequest"}}},"description":"Tool IDs (1-100 before dedup), optional profile, annotation policy filters and wait budget","required":true},"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":"Preflight verdict and per-tool results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Validation error (malformed, oversized, doubled or unknown-field body; empty or oversized tool list; conflicting duplicate pins; unknown profile; wait_ms out of range)"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Missing or invalid credentials"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Runtime unavailable, evaluator infrastructure read failure, or the activity record could not be persisted"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Preflight required tools","tags":["tools"]}},"/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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot change the active profile)"},"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.\nrouting_mode is what /mcp is actually serving; pending_routing_mode carries a\nrestart-pending value persisted on disk (empty when there is none).\ntool_response_mode and direct_tool_response_mode report the two serialization axes, resolved.","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. Isolation: ` + "`" + `isolation.enabled` + "`" + ` is READ-ONLY (it reports the effective state on reads) and is rejected with 400; set the per-server override via ` + "`" + `isolation.enabled_override` + "`" + ` (true | false | null to clear, omit to leave unchanged). An unrecognized ` + "`" + `isolation.mode_override` + "`" + ` is rejected with 400.","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. Isolation: ` + "`" + `isolation.enabled` + "`" + ` is READ-ONLY (it reports the effective state on reads) and is rejected with 400; set the per-server override via ` + "`" + `isolation.enabled_override` + "`" + ` (true | false | null to clear, omit to leave unchanged). An unrecognized ` + "`" + `isolation.mode_override` + "`" + ` is rejected with 400.","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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read MCP session history"},"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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read MCP session history"},"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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read deployment-wide 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"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read the deployment telemetry 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"]}}}, "openapi": "3.1.0" }` diff --git a/oas/swagger.yaml b/oas/swagger.yaml index 90435f547..9831a38d0 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -4186,7 +4186,9 @@ paths: to the active configuration file. Entries are advisory: `ok` scripts are invocable, `ambiguous` names have both extensions, and `invalid` ones report why (empty, oversized, unreadable, non-regular). Read-only — there is no write surface - for stored scripts.' + for stored scripts. Administrator-only (Spec 105 FR-012): an agent token, + whatever its server scope, is refused with 403 — the listing is the enumeration + the missing-script error withholds from a scoped caller.' responses: "200": content: @@ -4194,6 +4196,12 @@ paths: schema: $ref: '#/components/schemas/contracts.SuccessResponse' description: Stored scripts and the directory they were read from + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Agent tokens cannot list stored scripts "500": content: application/json: diff --git a/specs/105-agent-scope-hardening/research.md b/specs/105-agent-scope-hardening/research.md index 34a48af4b..127a069f9 100644 --- a/specs/105-agent-scope-hardening/research.md +++ b/specs/105-agent-scope-hardening/research.md @@ -94,3 +94,9 @@ Per [prior-diff-assessment.md](prior-diff-assessment.md): port hunks 1–6 and 1 **Decision**: the `read_cache` gate decides **in O(1) from the fixed frame header alone** and never loads the producer snapshot or decodes the value it refuses. It admits when (a) the reader is an administrator kind (FR-001 caller-kind-first, D5; the anonymous kind still never reads an authenticated administrator's entry), (b) the reader is an **unrestricted agent** — `AllowedServers` contains `"*"`, no pin, no effective profile — whose permission tier set covers the producer's (recorded as bits in the header; a superset of anything an agent could have produced), or (c) the reader's **effective-authorization digest equals the producer digest** stored in the header: SHA-256 of the canonical snapshot — caller kind, principal, sorted/deduplicated `AllowedServers`, sorted `Permissions`, `ProfilePin`, `ProfileScoped`, sorted `ProfileServers`; the profile *name* is excluded (the gate compares server sets, not names; a stale pin keeps its name while resolving to deny-all, which the header's deny-all bit refuses first). **Every other reader is refused** without loading anything: in particular a **strictly wider but bounded** reader — an `{a,b}` grant over an `{a}` entry, an unscoped session over its own profiled entry, the same user with a grown grant — is now refused. FR-001 obliges the door to refuse non-supersets; it does not oblige it to admit every superset, so refusing that rare shape is fail-closed and permitted; the predicate cells that pinned its admission are inverted with names stating the new contract. The snapshots bucket stays, keyed by the same digest, **for administrator diagnostics only** — no read path consults it. **Legacy (pre-frame / undecodable-frame) entries** are refused and deleted **without decoding**: the entry count is folded out exactly (no decode needed), the size is not (unknown without a payload-sized decode), and the existing cleanup sweep — which walks and decodes every record anyway — **recomputes `TotalEntries`/`TotalSizeBytes` exactly from the bucket**, so statistics are eventually consistent (over-count bounded by the legacy payload, for at most one `CleanupInterval`) while the refusal stays O(1). **Rationale**: codex rounds 2–5 kept finding the same root: any refusal that must *load* the producer snapshot (round 4's content-addressed snapshot behind a 128-entry LRU was O(1) only when warm — a restart or 129 distinct snapshots made the first probe decode a fleet-sized snapshot) or *decode* the legacy value (round 4's one-shot `preFramePayloadSize`) does work proportional to hidden state, which the spec's non-disclosing refusal forbids in the timing class. Measured before the fix: a cold scope refusal against a 5,000-server producer allocated 1,657,864 B against a 58,104 B miss; a legacy 4 MB entry's refusal allocated 8,500,992 B. After: wide = small = miss = 25,112 B on every probe, cold included. Including the principal in the digest is what lets a user be gated on the header (identity is necessary for users) and makes "digest-equal" mean *the same credential* for agents; a same-scope sibling agent is therefore refused too (fail-closed, same rule). **Alternatives**: a larger LRU or pre-warming at startup (still O(snapshot) somewhere, and a warm cache is itself hidden state); a bounded snapshot in the header (round 3, rejected in round 4: refused legitimate fleets); keeping the one-shot legacy decode (round 4, rejected in round 5: it is the oracle). Rejected. + +## H0 — accepted residual: unbounded per-rebuild index memory (codex r12 SHOULD, finding 2) + +**Decision**: not fixed. `internal/codescripts`'s directory-index rebuild (`dirfd_other.go`, `storednames_other.go`, `storednames_windows.go`) reads a full entry listing into a slice/map per rebuild with no per-directory size or byte cap; a scripts directory with an extreme entry count could make `Warm` or an async rebuild allocate proportionally. Left unbounded because rebuild concurrency is already capped process-wide at 2 (`maxConcurrentRebuilds`, `rebuildsemaphore.go`), the scripts directory is operator-controlled (not agent-writable), and bounding it is real scope beyond H0's SC-005 disclosure fixes. Revisit only if a real deployment reports memory pressure from this path. + +Round 17 SHOULD (codex r16, finding 1) also noted that `Warm` now shares `rebuildSlots` with the async path, so an older, still-uncancelled `Warm(A)` can occasionally finish rebuilding an index that a newer, concurrent `Warm(B)` has already pruned from `storedIndexes` (`pruneOtherIndexesLocked`) — an accepted residual, not fixed: the wasted listing installs into an index nothing can reach any more (`A`'s key is gone from the map, so no request or later `Warm(A)` sees it), the process simply pays for a listing it throws away, and the very next request or `Warm` call against `A` triggers a fresh rebuild against the current directory state — never serving stale or incorrect data, only wasting one rebuild's worth of work. diff --git a/specs/105-agent-scope-hardening/tasks.md b/specs/105-agent-scope-hardening/tasks.md index 1cba92b43..8f1af9b97 100644 --- a/specs/105-agent-scope-hardening/tasks.md +++ b/specs/105-agent-scope-hardening/tasks.md @@ -159,18 +159,18 @@ ### Failing tests -- [ ] T062 [US1] FR01x-G1: agent ctx `["*"]`, script `gamma`, `alpha-SENTINEL.js` present → IsError, no `SENTINEL`/`Available scripts`, byte-equal to empty-dir proxy; admin control kept; positive controls per `spec.md:116`: `a`-only token runs a stored script returning a constant (no upstream call) and gets the constant; a stored script calling `b` is refused at the nested call; scoped initialization publishes custom `instructions` mentioning `b:private_search` (documented) — `internal/server/mcp_code_scripts_test.go` + `internal/server/mcp_instructions_scope_test.go` (new) -- [ ] T063 [P] [US1] FR01x-G2: `TestCodeExecutionDescriptions_EnumerationIsAdminOnly` asserting `code_execution.description` and `script.description` no longer advertise enumeration, and a golden-delta assertion that only those two strings changed — `internal/server/toolslist_snapshot_test.go` +- [x] T062 [US1] FR01x-G1: agent ctx `["*"]`, script `gamma`, `alpha-SENTINEL.js` present → IsError, no `SENTINEL`/`Available scripts`, byte-equal to empty-dir proxy; admin control kept; positive controls per `spec.md:116`: `a`-only token runs a stored script returning a constant (no upstream call) and gets the constant; a stored script calling `b` is refused at the nested call; scoped initialization publishes custom `instructions` mentioning `b:private_search` (documented) — `internal/server/mcp_code_scripts_test.go` + `internal/server/mcp_instructions_scope_test.go` (new) +- [x] T063 [P] [US1] FR01x-G2: `TestCodeExecutionDescriptions_EnumerationIsAdminOnly` asserting `code_execution.description` and `script.description` no longer advertise enumeration, and a golden-delta assertion that only those two strings changed — `internal/server/toolslist_snapshot_test.go` ### Implementation -- [ ] T064 [US1] Caller-kind branch: enumeration only for non-scoped callers — `internal/server/mcp_code_execution.go:473-499` (or `internal/codescripts/codescripts.go:338-356` with a caller flag) -- [ ] T065 [US1] Reword `internal/server/mcp_code_execution.go:52-53,73-74`; regenerate goldens with `MCPPROXY_WRITE_TOOLSLIST_GOLDENS=testdata/toolslist_goldens go test -run TestToolsListSnapshot ./internal/server/` (the variable is the OUTPUT DIRECTORY, `toolslist_snapshot_test.go:151-158`), then rerun with it unset; diff limited to `internal/server/testdata/toolslist_goldens/{default_server,retrieve_tools_mode,code_execution_mode}.json` -- [ ] T066 [P] [US1] Docs: enumeration is admin-only in `docs/code_execution/overview.md:379-387`, `cookbook.md:141`, `troubleshooting.md:604-613`, `api-reference.md:591`; add invariant sentence, covered-surface list (`/mcp`, `/mcp/all`, `/mcp/code`, `/mcp/call`, `/mcp/p/`, aliases), retained-effects list and custom-instructions/stored-scripts secrets warning to `docs/features/agent-tokens.md` (FR01x-G3) +- [x] T064 [US1] Caller-kind branch: enumeration only for non-scoped callers — `internal/server/mcp_code_execution.go:473-499` (or `internal/codescripts/codescripts.go:338-356` with a caller flag). Critique r1: the scoped form (`codescripts.ResolveScoped`) never lists the directory and strips host paths / OS errors from the ambiguous and unusable refusals too; `GET /api/v1/code/scripts` is gated with `requireAdminRead` (403 for agent tokens) +- [x] T065 [US1] Reword `internal/server/mcp_code_execution.go:52-53,73-74`; regenerate goldens with `MCPPROXY_WRITE_TOOLSLIST_GOLDENS=testdata/toolslist_goldens go test -run TestToolsListSnapshot ./internal/server/` (the variable is the OUTPUT DIRECTORY, `toolslist_snapshot_test.go:151-158`), then rerun with it unset; diff limited to `internal/server/testdata/toolslist_goldens/{default_server,retrieve_tools_mode,code_execution_mode}.json` +- [x] T066 [P] [US1] Docs: enumeration is admin-only in `docs/code_execution/overview.md:379-387`, `cookbook.md:141`, `troubleshooting.md:604-613`, `api-reference.md:591`; add invariant sentence, covered-surface list (`/mcp`, `/mcp/all`, `/mcp/code`, `/mcp/call`, `/mcp/p/`, aliases), retained-effects list and custom-instructions/stored-scripts secrets warning to `docs/features/agent-tokens.md` (FR01x-G3) ### Verification -- [ ] T067 [US1] Common verification; `git diff --stat -- internal/server/testdata` shows only the three goldens +- [x] T067 [US1] Common verification; `git diff --stat -- internal/server/testdata` shows only the three live goldens plus their deliberately frozen pre-105 copies (`toolslist_goldens/pre105/*.json`, byte-identical to the merge base — the baseline the golden-delta assertion diffs against) - [~] T068 [US1] Astra rounds on FR-012 + FR01x-G1…G3; quote final `VERDICT:` ---