From 346c06c91ea9ef129b629ff1896bfec064572f5c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 15 Aug 2026 09:50:34 +0300 Subject: [PATCH 1/9] spec: required-tools preflight (098) Related #969 Spec for item 3 of #969: deterministic side-effect-free availability check for caller-supplied tool IDs. Shared eligibility evaluator, 15-code failure enum with precedence chain, REST POST /api/v1/preflight, CLI with typed exit codes 0/10/11/12, activity-log transparency, sabotage E2E matrix. Cross-model reviewed (opencode/gpt-5.6-sol, 3 rounds, APPROVE). --- .../checklists/requirements.md | 35 ++++ specs/098-tools-preflight/spec.md | 176 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 specs/098-tools-preflight/checklists/requirements.md create mode 100644 specs/098-tools-preflight/spec.md diff --git a/specs/098-tools-preflight/checklists/requirements.md b/specs/098-tools-preflight/checklists/requirements.md new file mode 100644 index 00000000..f66a621b --- /dev/null +++ b/specs/098-tools-preflight/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Required-Tools Preflight + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-08-15 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details beyond what locked decisions require (enum/precedence/surfaces are product contract, not implementation; internal seam names appear only in Assumptions/FR-002 as reconciliation scope) +- [x] Focused on user value and business needs (silent failure → legible failure; token savings preserved) +- [x] Written for non-technical stakeholders (user stories readable standalone) +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain (all decisions pre-locked 2026-08-15, reporter-confirmed 2026-08-13) +- [x] Requirements are testable and unambiguous (each FR names its observable behavior) +- [x] Success criteria are measurable (SC-001…SC-006) +- [x] Success criteria are technology-agnostic (framed as outcomes; SC-002 latency/IO framed as caller-observable) +- [x] All acceptance scenarios are defined (Stories 1–4) +- [x] Edge cases are identified (co-occurring states, empty/dup/malformed IDs, batch cap, degraded runtime, wait under load, server edition, Windows, no side effects) +- [x] Scope is clearly bounded (Non-Goals lists every deferred phase) +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows (cron/CI, REST harness, transparency audit, MCP-surface guard-rail) +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into user-facing sections + +## Notes + +- Enum names, precedence order, exit codes, and disclosure tiers are deliberate product contract (locked decision log, research artifact §11) — kept verbatim in the spec because consumers branch on them. +- Cross-model review of this spec (opencode / GPT Sol) required before /speckit.plan proceeds per project convention. diff --git a/specs/098-tools-preflight/spec.md b/specs/098-tools-preflight/spec.md new file mode 100644 index 00000000..95642ba9 --- /dev/null +++ b/specs/098-tools-preflight/spec.md @@ -0,0 +1,176 @@ +# Feature Specification: Required-Tools Preflight + +**Feature Branch**: `098-tools-preflight` +**Created**: 2026-08-15 +**Status**: Draft +**Input**: User description: "Required-tools preflight (issue #969 item 3): a deterministic, side-effect-free availability check for a caller-supplied list of tool IDs, answering per-ID with a machine-readable reason code. Locked decisions from the research report 'Required-Tools Preflight — Research & Alternatives (mcpproxy #969)' (decision log 2026-08-15) and reporter-confirmed shape (issue #969 comment, 2026-08-13)." + +**Related**: #969 (item 3). Item 1 (filter_diagnostics) shipped in spec 094 / v0.55.0. This spec is v1 (Phase 1) of the preflight roadmap: shared eligibility evaluator + REST + CLI. Non-goals list the deferred phases. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Cron/CI job gates on required tools before spending model tokens (Priority: P1) + +An operator runs a recurring headless automation (cron, launchd, CI) that depends on a small, stable set of tools (e.g. `gh-ops:sync_issues`, `slack:post_message`). Before the agent session starts, the job runs one preflight command. If every required tool is ready, the job proceeds. If not, the job fails deterministically — before any model tokens are spent — with a per-tool, machine-readable reason (quarantined, disabled, unhealthy, unknown ID, …) and a remediation hint, and the wrapper can branch on the exit code alone: retry later, page the operator, or fail the pipeline. + +**Why this priority**: This is the reporter's confirmed primary flow ("fail deterministically before an agent session spends tokens on discovery") and the core of issue #969 item 3. Without it, a quarantine flip or upstream failure surfaces as a silent discovery miss and an ambiguous agent failure — the measured cost of that ambiguity in the wild is days-to-weeks of diagnosis. + +**Independent Test**: Run `mcpproxy tools preflight ` against a local proxy with sabotaged fixtures (one tool quarantined, one server disabled, one ID misspelled) and assert the exit code and per-tool reason codes without any other feature present. + +**Acceptance Scenarios**: + +1. **Given** all required tools are indexed, approved, on enabled healthy servers, **When** the preflight runs, **Then** it exits 0 and reports every tool `ready` — without contacting any upstream server. +2. **Given** one required tool's definition changed after approval (rug-pull guard tripped), **When** the preflight runs, **Then** it exits 11 (blocked), that tool reports `tool_changed` with `retryable: false`, action `approve`, and a remediation pointing at the diff/review flow. +3. **Given** one required server is still connecting/indexing, **When** the preflight runs, **Then** it exits 10 (retryable), the affected tools report `server_initializing` with `retryable: true`, and the job can back off and retry. +4. **Given** a misspelled tool ID, **When** the preflight runs, **Then** it exits 12 (unknown ID), the entry reports `not_found` with a scope-filtered `did_you_mean` suggestion. +5. **Given** a mix of failures, **When** the preflight runs, **Then** the exit code is the worst class present (12 > 11 > 10) and the JSON output lists every per-tool verdict. + +--- + +### User Story 2 - Automation script calls the REST preflight directly (Priority: P1) + +A platform engineer's harness (n8n, GitHub Actions, a Python script) calls `POST /api/v1/preflight` with the automation's required tool list (optionally pinned by schema hash, optionally under a named profile) and branches on the structured response. The verdict is data: HTTP status is 200 whenever the check itself executed; the body carries the set-level verdict and per-tool results. + +**Why this priority**: Same journey as Story 1 without the CLI dependency — anything that can speak HTTP can gate on required tools. The CLI is a wrapper over this endpoint, so the endpoint is the foundation. + +**Independent Test**: `curl -X POST /api/v1/preflight` with a JSON body of tool IDs against a fixture proxy; assert response shape, verdicts, and that the same request under a `profile` reflects that profile's visibility. + +**Acceptance Scenarios**: + +1. **Given** a valid API key and a list of tool IDs, **When** POSTing to `/api/v1/preflight`, **Then** the response contains `verdict`, `checked_at`, and one result per requested unique ID carrying `id` and `status`; `unavailable` results additionally carry `reason`, `retryable`, `action`, `detail`, `remediation` (ready results omit failure fields). +2. **Given** a `pin_hash` that no longer matches the tool's current schema hash, **When** the preflight runs, **Then** that tool reports `hash_mismatch` (blocked class), even though the tool is otherwise ready. +3. **Given** a `profile` parameter, **When** the preflight runs, **Then** verdicts reflect that profile's server scope and per-profile index — a tool outside the profile reports `not_found`, matching what a profile-pinned session would experience. +4. **Given** `wait_ms` is supplied and a failure is retryable-only, **When** the state becomes ready within the deadline, **Then** the response reports `ready`; **When** the deadline passes first, **Then** the response resolves (never hangs) with the current reasons. +5. **Given** an agent-token caller whose scope excludes a requested server, **When** the preflight runs, **Then** the out-of-scope tool reports plain `not_found` (no existence leak, no cross-scope `did_you_mean`, no hashes), while the same request with the operator API key reports `server_not_in_scope` with the full diagnosis. + +--- + +### User Story 3 - Operator audits preflight activity for the transparency story (Priority: P2) + +An operator (or a curious user reviewing what their agents did) opens the activity log and sees every preflight call: when it ran, what was asked, the per-tool verdicts, and the request ID that correlates it with any subsequent tool calls in the same workflow. A failed nightly job is diagnosable next morning from the activity log alone. + +**Why this priority**: Transparency is a stated product pillar; a diagnostic feature whose own runs are invisible would undercut it. Also the substrate for later effectiveness metrics (silent-vs-reasoned trend). + +**Independent Test**: Run one preflight (REST or CLI), then `mcpproxy activity list` and assert a preflight record exists with the request ID, requested IDs, and verdict summary; open the Web UI activity view and confirm it renders. + +**Acceptance Scenarios**: + +1. **Given** any preflight call, **When** it completes, **Then** an activity record is written carrying the request ID, the number of requested IDs, the set-level verdict, and per-tool reason codes. +2. **Given** an activity record from a preflight, **When** the operator runs `mcpproxy activity list --request-id `, **Then** the preflight record is returned and browsable alongside other records from the same workflow. +3. **Given** the Web UI activity view, **When** a preflight record is present, **Then** it renders with its verdict without breaking existing activity rendering. + +--- + +### User Story 4 - Agent-driven flows keep working everywhere the proxy is used (Priority: P3) + +An agent operating through mcpproxy's MCP surfaces (retrieve_tools mode, code_execution mode, stored server-side scripts) is unaffected by the feature when it doesn't use it: MCP tool schemas are byte-identical, and code-execution scripts can reach the preflight only the same way any REST consumer can. Where an agent needs an in-band check today, the documented interim path is the existing `describe_tool` codes; the dedicated in-band check mode is Phase 2. + +**Why this priority**: Guard-rail story — the feature must not tax or destabilize the token-minimized MCP surface that is the product's core value. + +**Independent Test**: Diff `tools/list` payloads for all three routing modes between main and the feature branch (must be identical); run a code_execution script and a stored script end-to-end and confirm behavior is unchanged. + +**Acceptance Scenarios**: + +1. **Given** any MCP client session in any routing mode, **When** listing tools, **Then** the registered tool schemas are byte-identical to the previous release. +2. **Given** a code_execution or stored-script run, **When** it executes upstream tools, **Then** behavior and dispatch gating are unchanged, and dispatch decisions remain consistent with what a preflight of the same tools would report (no visibility/enforcement skew). + +--- + +### Edge Cases + +- **Co-occurring states**: a nonexistent tool on a quarantined server reports `server_quarantined` (existence is unknowable there — quarantined servers' tools are never indexed); precedence is fixed and documented (see FR-004). +- **Empty tool list**: rejected with a validation error (400) — an empty preflight is a caller bug, not a trivially-green check. +- **Duplicate IDs**: deduplicated; the response contains one result per unique ID. +- **Malformed ID** (no `server:tool` separator): per-ID `not_found` with a format hint in `detail`, not a request-level error — one bad entry must not mask verdicts for the rest. +- **Batch limits**: at most 100 IDs per request; oversized requests get a 400 with the limit named. +- **Runtime not available** (degraded process state): the served endpoint refuses with 503 rather than emitting reduced-fidelity verdicts (FR-006); storage-only evaluation exists only in unit tests. +- **`wait_ms` under load**: capped at 10 000 ms; the waiting request counts against the concurrency-shed budget (spec 093) so a flood of waiting preflights cannot starve real traffic; polling uses a floor interval so waiting adds no meaningful load. +- **Server edition, multi-user OAuth**: verdicts are operator-view (global connection state). `oauth_required` may not reflect the calling user's own token state; documented, with `as_user` reserved for a future revision (non-goal here). +- **Windows**: the named pipe carries the same admin-context semantics as the Unix socket; the CLI works identically. +- **Never triggers side effects**: a preflight must never initiate connects, reconnects, re-indexing, or any upstream I/O — observational only, even when it finds a dead server. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001 (Evaluator)**: System MUST provide a single shared eligibility evaluator that, given a tool ID and a caller context (auth tier, optional profile, optional policy filters, optional pin hash), returns either `ready` or exactly one failure reason from the closed enum (FR-003). All preflight surfaces MUST use this evaluator. +- **FR-002 (No-skew)**: The evaluator's verdict semantics MUST be consistent with call-time dispatch gating **for the gates the evaluator represents** (existence, scope, quarantine, approval status, tool/user/config disablement): any such state that causes dispatch to refuse a tool MUST map to a non-`ready` preflight verdict, and the existing per-tool gates (dispatch inline checks, describe-gate reasons, locked-tool classification) MUST be reconciled so they cannot disagree with the evaluator. Divergences found during consolidation MUST be resolved in favor of dispatch behavior (dispatch is ground truth) and covered by tests. Explicit carve-out: connection-time failures that only manifest during a live call (network races, per-user OAuth state in the server edition) are outside the no-skew guarantee — preflight is a point-in-time eligibility check, not a call-success guarantee. +- **FR-003 (Reason enum)**: A per-tool result has `status: ready | unavailable`. `ready` is the success status, not a failure reason. For `unavailable`, the closed v1 failure-reason enum is exactly 15 codes, per the normative table below. Each result carries `id` (the requested unique ID), `status`, and for failures `reason`, `retryable` (bool), `action` (existing health-action vocabulary: `login`/`restart`/`enable`/`approve`/`view_logs`/`set_secret`/`configure`/`none`), `detail`, `remediation`. Evolution is additive-only; consumers are instructed to treat unknown codes as non-retryable. `server_saturated` is reserved (not implemented). `server_not_in_scope` is emitted at the operator tier only; the agent-token tier maps that state to `not_found` (see FR-013). + + | Reason | Class | `retryable` | Default `action` | Set verdict | CLI exit | + |---|---|---|---|---|---| + | `server_initializing` | retryable | true | none | degraded_retryable | 10 | + | `server_unhealthy` | retryable | true | best-effort from diagnostics (`restart`/`login`/`view_logs`; default `view_logs`) | degraded_retryable | 10 | + | `server_disabled` | fix-state-first | false | enable | blocked | 11 | + | `server_quarantined` | fix-state-first | false | approve | blocked | 11 | + | `tool_pending_approval` | fix-state-first | false | approve | blocked | 11 | + | `tool_changed` | fix-state-first | false | approve | blocked | 11 | + | `tool_blocked_by_user` | fix-state-first | false | enable | blocked | 11 | + | `oauth_required` | fix-state-first | false | login | blocked | 11 | + | `hash_mismatch` | fix-state-first | false | configure | blocked | 11 | + | `server_not_in_scope` (operator tier only) | permanent-config | false | configure | blocked | 11 | + | `tool_denied_by_config` | permanent-config | false | configure | blocked | 11 | + | `missing_annotation` | permanent-config | false | configure | blocked | 11 | + | `policy_filtered` | permanent-config | false | none | blocked | 11 | + | `not_found` | permanent-config | false | configure | unknown_ids | 12 | + | `server_not_configured` | permanent-config | false | configure | unknown_ids | 12 | + + Set-level verdict and CLI exit code are the worst class present, ordered `unknown_ids` (12) > `blocked` (11) > `degraded_retryable` (10) > `ready` (0). +- **FR-004 (Precedence)**: When multiple states co-occur for one ID, the first match in this order wins: `server_not_configured` → `server_not_in_scope` (operator tier; token tier reports `not_found` here) → `server_quarantined` → `server_disabled` → `not_found` → `tool_denied_by_config` → `tool_blocked_by_user` → `tool_changed` → `tool_pending_approval` → `hash_mismatch` → `oauth_required` → `server_unhealthy` → `server_initializing` → annotation filters → `ready`. Hash-pin validation runs at its precedence slot: it applies only once the tool is known to exist with a current stored hash; earlier states win over `hash_mismatch`. Annotation-filter classification follows the spec 094 convention: filters are evaluated in the fixed order `read_only_only` → `exclude_destructive` → `exclude_open_world`, the first filter that excludes owns the omission, and within that filter the verdict is `missing_annotation` when the hint is absent and `policy_filtered` when the hint is explicitly unsafe — the two are mutually exclusive per owning filter, so their ordering is deterministic. +- **FR-005 (State sources)**: Verdicts MUST be computed exclusively from local state: index existence, tool-approval records and hashes (spec 032), connection-state snapshot and health classification, config policy (enabled/disabled tools), and annotation classification (spec 094 `excludeReason`) when policy filters are supplied. `server_initializing` is a server-level verdict; the system MUST NOT claim per-tool knowledge ("your tool will appear") during discovery/indexing. +- **FR-006 (Zero upstream I/O)**: A preflight MUST perform zero upstream server calls and MUST NOT mutate proxy runtime state (no connects, reconnects, re-index, config or approval changes). "Side-effect-free" is defined as no upstream I/O and no runtime mutation; the local activity-log write required by FR-014 is explicitly permitted and expected. Any helper with a live-call fallback MUST be excluded or guarded. Asserted by an automated test using an instrumented transport. If the runtime is unavailable when a preflight arrives (degraded process state), the endpoint MUST refuse with 503 (`preflight unavailable`) rather than emit reduced-fidelity verdicts; storage-only evaluation exists only inside unit tests, never on the served surface. +- **FR-007 (PendingAuth)**: The PendingAuth/deferred-OAuth connection state MUST map to `oauth_required` with `retryable: false` and action `login` (waiting does not help without a login). +- **FR-008 (REST surface)**: `POST /api/v1/preflight` MUST accept `{tools: [{id, pin_hash?}…], profile?, policy?{read_only_only, exclude_destructive, exclude_open_world}, wait_ms?}` and return `{verdict, checked_at, waited_ms?, tools: [per-ID results]}` with HTTP 200 whenever the check executed (validation errors: 400; runtime unavailable: 503). Set-level `verdict` per the FR-003 table. Every result carries its requested `id`; results are ordered by first occurrence of each unique ID in the request. Duplicate IDs are deduplicated; duplicates carrying **different** `pin_hash` values are a validation error (400). Requests rejected with 400/503 did not execute a preflight and write no preflight activity record (standard HTTP request handling applies). +- **FR-009 (CLI surface)**: `mcpproxy tools preflight … [--profile P] [--pin id=hash] [--read-only-only] [--exclude-destructive] [--exclude-open-world] [--wait duration] [-o json|yaml|table]` MUST wrap the endpoint with exit codes: 0 all ready · 10 degraded-retryable · 11 blocked (operator action) · 12 unknown ID present; worst class wins. Transport/other CLI failures use the existing general exit code 1. The command MUST follow existing CLI output conventions (`-o`, `MCPPROXY_OUTPUT`, `--help-json`). +- **FR-010 (Profiles)**: With `profile` supplied, evaluation MUST run under that profile's server scope and per-profile index so the verdict matches a profile-pinned session's experience. Without it, evaluation is the unscoped operator view (documented). Unknown profile: 400. +- **FR-011 (Hash pinning)**: With `pin_hash` supplied for an ID, a divergence from the tool's current stored hash MUST report `hash_mismatch`. The pin format MUST embed the hash schema version so proxy-side hash-algorithm bumps are distinguishable from genuine upstream drift; current hashes at the current schema version MUST be discoverable via existing surfaces so pins can be created. +- **FR-012 (wait_ms)**: With `wait_ms > 0` (cap 10 000), when all current failures are retryable-class the system MUST poll local state until every tool is ready or the deadline passes; it terminates early the moment any non-retryable failure appears (waiting cannot help), MUST always resolve with current reasons at the deadline (never hang), MUST use a polling floor (≥250 ms), and the waiting request MUST count against the spec 093 concurrency-shed budget. +- **FR-013 (Disclosure tiers)**: Operator tier (API key / socket / named pipe): full results including hashes and the `server_not_in_scope` diagnosis (emitted when a supplied `profile` — or, in a later phase, a named token scope — excludes an existing server). Agent-token tier: scope-silence — an out-of-scope ID's **entire result** MUST be byte-indistinguishable from an ordinary `not_found` (same `reason`, `retryable`, `action`, `detail`, `remediation` wording; no hashes; no `did_you_mean` crossing the scope boundary). `did_you_mean` is a nearest-name suggestion on `not_found`, computed over the caller-visible index only and never suggesting quarantined-tool names. +- **FR-014 (Activity log)**: Every executed preflight (i.e., any request answered 200) MUST write an activity record carrying the request ID, requested-ID count, set-level verdict, and per-tool reason codes, correlated via X-Request-Id and browsable via `mcpproxy activity list` and the Web UI activity view. Records MUST NOT leak tool names to any telemetry surface (activity log is local-only; telemetry, if any, carries counts and enum codes only). +- **FR-015 (MCP surface untouched)**: No MCP tool schema changes in any routing mode: `tools/list` payloads MUST be byte-identical to the prior release across default, retrieve_tools, and code_execution modes. Verified by a snapshot test. +- **FR-016 (Sabotage test matrix)**: E2E tests MUST deliberately induce each reason state (quarantine flip, tool-definition drift, tool block, config denial, server disable, server kill/disconnect, mid-indexing, missing/explicit annotations under filters, unknown ID, unknown server, hash mismatch, deferred OAuth, out-of-scope under a profile at both disclosure tiers) and assert the exact reason code, `retryable` flag, and action per cell. Adding an enum code without its cell MUST fail review/CI. +- **FR-017 (Docs)**: Documentation MUST be updated: REST API reference (endpoint + schema), CLI reference (command + exit codes), a feature page for preflight (concept, taxonomy table, cron/CI recipes, agent-workflow examples), and expanded usage examples covering token-saving discovery flows and common agent actions with mcpproxy connected, including how preflight composes with code_execution and stored scripts (via REST from the harness; in-band check mode is Phase 2). + +### Key Entities + +- **Preflight request**: caller-supplied list of tool IDs (`server:tool`), each optionally hash-pinned; optional profile, policy filters, wait budget. +- **Per-tool verdict**: reason code (closed enum), retryable flag, action, human detail, remediation string; optionally hash (operator tier) and `did_you_mean` (on `not_found`). +- **Set-level verdict**: worst-class aggregate (`ready` / `degraded_retryable` / `blocked` / `unknown_ids`) driving the CLI exit code. +- **Activity record (preflight)**: request ID, timestamp, requested-ID count, set verdict, per-tool reasons — local activity log only. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: For every induced failure state in the sabotage matrix, the preflight names the correct reason with the correct retryability — 100% of cells, enforced in CI. +- **SC-002**: A preflight of 10 tools completes in under 50 ms (p95) measured by a committed Go benchmark at the handler level (evaluator + response + activity-record build, excluding HTTP transport) against the repo's fixture corpus on CI-class hardware, and performs zero upstream calls (hard assertion via instrumented transport, not a threshold). +- **SC-003**: A cron wrapper can branch retry-vs-page-vs-fix using only the exit code — no JSON parsing — in all acceptance scenarios of Story 1. +- **SC-004**: MCP client sessions pay zero additional context tokens: `tools/list` payloads are byte-identical across all routing modes. +- **SC-005**: Every preflight run is discoverable in the activity log within the same session by request ID; a scripted "failed nightly job" scenario is diagnosable from the activity log alone (correct root cause named) without server logs. +- **SC-006**: The scripted replay of the deactivated-tool incident class (tool quarantined between runs) reaches a named root cause in ≤1 step after the preflight response, versus never (silent failure) on the baseline. + +## Non-Goals (v1) + +- In-band MCP surface (`describe_tool` check mode) — Phase 2; interim in-band guidance is the existing `describe_tool` per-ID codes (batch ≤5). +- `readyz` probe endpoint, SSE readiness events, `tools/list_changed` emission. +- Tool lockfile (`mcpproxy tools lock/verify`) and registered automation contracts with change-time warnings. +- Agent-token-carried required-tools contracts; MCP extension (`app.mcpproxy/required-tools`) — later phase; identifier and negotiation shape per the 2026-08-15 SEP verification. +- Per-user verdicts in the server edition (`as_user` reserved); spec 093 queue-saturation verdicts (`server_saturated` reserved). +- Refresh/liveness operations — preflight describes proxy state only; refresh remains a separate explicit operation. + +## Assumptions + +- The reporter-confirmed contract (issue #969 comment, 2026-08-13) is authoritative for v1 shape: cron/CI-first, REST+CLI, stat-only. +- The three existing per-tool gate implementations are known to disagree in edge cases (quarantine-enabled flag handling; changed-vs-pending collapse); reconciling them is in scope and dispatch behavior is ground truth. +- Activity-log record schema can be extended with a new record kind without breaking existing consumers (CLI + Web UI render unknown kinds generically or are updated in this feature). +- The research artifact and its §11 decision log (2026-08-15) are the design record; this spec implements Phase 1 only. + +## Commit Message Conventions *(mandatory)* + +### Issue References +- ✅ **Use**: `Related #969` +- ❌ **Do NOT use**: `Fixes #969`, `Closes #969`, `Resolves #969` + +### Co-Authorship +- ❌ **Do NOT include**: `Co-Authored-By: Claude ` +- ❌ **Do NOT include**: "🤖 Generated with [Claude Code](https://claude.com/claude-code)" From 7c20444a6a9eb870831a77cd0c32a7b5f5edf3a1 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 15 Aug 2026 10:10:05 +0300 Subject: [PATCH 2/9] plan: required-tools preflight design artifacts (098) Related #969 plan.md, research.md, data-model.md, contracts/preflight-api.yaml, quickstart.md, tasks.md. Cross-model reviewed (opencode/gpt-5.6-sol): round 1 returned 25 findings incl. 12 P1s (activity seam ownership, swagger generation path, nonexistent 093 HTTP shed budget, ForProfile mutation, dropped agent-token ProfilePin, dispatch fail-open directionality); all incorporated; rounds 2-3 APPROVE. --- CLAUDE.md | 2 +- ROADMAP.md | 1 + .../contracts/preflight-api.yaml | 94 ++++++++++++++++ specs/098-tools-preflight/data-model.md | 87 +++++++++++++++ specs/098-tools-preflight/plan.md | 102 ++++++++++++++++++ specs/098-tools-preflight/quickstart.md | 76 +++++++++++++ specs/098-tools-preflight/research.md | 67 ++++++++++++ specs/098-tools-preflight/spec.md | 16 +-- specs/098-tools-preflight/tasks.md | 73 +++++++++++++ 9 files changed, 509 insertions(+), 9 deletions(-) create mode 100644 specs/098-tools-preflight/contracts/preflight-api.yaml create mode 100644 specs/098-tools-preflight/data-model.md create mode 100644 specs/098-tools-preflight/plan.md create mode 100644 specs/098-tools-preflight/quickstart.md create mode 100644 specs/098-tools-preflight/research.md create mode 100644 specs/098-tools-preflight/tasks.md diff --git a/CLAUDE.md b/CLAUDE.md index d4dc9785..a8311558 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,6 +158,6 @@ tail -f ~/Library/Logs/mcpproxy/main.log # main log (macOS; Linux: ~/.mcpproxy/ - **Windows installer**: [docs/github-actions-windows-wix-research.md](docs/github-actions-windows-wix-research.md). **Prerelease** (`next` branch + `v*-rc.*` tags, opt-in, off stable channels): [docs/prerelease-builds.md](docs/prerelease-builds.md). ## Recent Changes +- 098-tools-preflight: Added Go 1.24 module toolchain (repo builds with local Go 1.25) + existing only — chi (httpapi), bbolt (storage), Bleve (index), zap (logging), Cobra (CLI), swaggo/swag v2 (contract regen). **No new dependencies.** - 097-stored-scripts: Added Go 1.25 (os.Root/Root.ReadFile available — R1) + stdlib only (os.Root). **No new dependencies.** - 096-batched-call-tools: Added Go 1.24 module toolchain (repo builds with local Go 1.25) + existing only — goja (sandbox), mark3labs/mcp-go (tool surface), zap. **No new dependencies.** -- 095-update-failure-ux: Added Swift 5.9 (tray, AppKit + Sparkle 2.9.3 vendored via SwiftPM) · Go 1.24 module toolchain (repo builds with local Go 1.25) + existing only — Sparkle 2.9.3 (`SPUUpdater`, `SPUStandardUserDriver`), chi (httpapi), bbolt (diagnostics counters), swaggo/swag v2 (contract regen). **No new dependencies.** diff --git a/ROADMAP.md b/ROADMAP.md index 5c32426f..2af9533f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -799,3 +799,4 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [095-update-failure-ux](./specs/095-update-failure-ux/) | `shipped` | 28/28 (100%) | | [096-batched-call-tools](./specs/096-batched-call-tools/) | `in-flight` | 15/16 (94%) | | [097-stored-scripts](./specs/097-stored-scripts/) | `in-flight` | 13/14 (93%) | +| [098-tools-preflight](./specs/098-tools-preflight/) | `drafted` | 0/33 (0%) | diff --git a/specs/098-tools-preflight/contracts/preflight-api.yaml b/specs/098-tools-preflight/contracts/preflight-api.yaml new file mode 100644 index 00000000..2f9205b3 --- /dev/null +++ b/specs/098-tools-preflight/contracts/preflight-api.yaml @@ -0,0 +1,94 @@ +# OpenAPI fragment: POST /api/v1/preflight (folded into oas/swagger.yaml via make swagger) +paths: + /api/v1/preflight: + post: + summary: Deterministic availability check for a list of tool IDs + description: > + Side-effect-free (no upstream I/O, no runtime mutation; writes a local activity record). + Verdicts are computed from local proxy state only. HTTP 200 whenever the check executed; + the availability verdict is in the body, never the HTTP status. + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [tools] + properties: + tools: + type: array + minItems: 1 + maxItems: 100 + items: + type: object + required: [id] + properties: + id: { type: string, example: "github:create_issue" } + pin_hash: { type: string, example: "sha256/v2:9f2c41ab..." } + profile: { type: string } + policy: + type: object + properties: + read_only_only: { type: boolean } + exclude_destructive: { type: boolean } + exclude_open_world: { type: boolean } + wait_ms: { type: integer, minimum: 0, maximum: 10000 } + responses: + "200": + description: Preflight executed + content: + application/json: + schema: + type: object + required: [verdict, checked_at, tools] + properties: + verdict: + type: string + enum: [ready, degraded_retryable, blocked, unknown_ids] + checked_at: { type: string, format: date-time } + waited_ms: { type: integer } + tools: + type: array + items: + type: object + required: [id, status] + properties: + id: { type: string } + status: { type: string, enum: [ready, unavailable] } + reason: + type: string + enum: + - server_initializing + - server_unhealthy + - server_disabled + - server_quarantined + - tool_pending_approval + - tool_changed + - tool_blocked_by_user + - oauth_required + - hash_mismatch + - server_not_in_scope + - tool_denied_by_config + - missing_annotation + - policy_filtered + - not_found + - server_not_configured + retryable: { type: boolean } + action: + type: string + enum: [login, restart, enable, approve, view_logs, set_secret, configure, none] + detail: { type: string } + remediation: { type: string } + hash: { type: string, description: "operator tier, ready results only" } + did_you_mean: + type: array + maxItems: 3 + items: { type: string } + "400": + description: Validation error (empty/oversized list, conflicting duplicate pins, unknown profile, wait_ms out of range). No preflight record written. + "401": + description: Missing/invalid API key or agent token + "503": + description: Runtime unavailable; preflight refused rather than degraded. No preflight record written. diff --git a/specs/098-tools-preflight/data-model.md b/specs/098-tools-preflight/data-model.md new file mode 100644 index 00000000..ffb881e4 --- /dev/null +++ b/specs/098-tools-preflight/data-model.md @@ -0,0 +1,87 @@ +# Data Model: Required-Tools Preflight + +## Enums + +### PreflightStatus +`ready` | `unavailable` + +### PreflightReason (closed, 15 codes, additive-only; unknown ⇒ treat as non-retryable) + +| Code | Class | retryable | action | Set verdict | Exit | +|---|---|---|---|---|---| +| server_initializing | retryable | true | none | degraded_retryable | 10 | +| server_unhealthy | retryable | true | view_logs (best-effort restart/login) | degraded_retryable | 10 | +| server_disabled | fix_state | false | enable | blocked | 11 | +| server_quarantined | fix_state | false | approve | blocked | 11 | +| tool_pending_approval | fix_state | false | approve | blocked | 11 | +| tool_changed | fix_state | false | approve | blocked | 11 | +| tool_blocked_by_user | fix_state | false | enable | blocked | 11 | +| oauth_required | fix_state | false | login | blocked | 11 | +| hash_mismatch | fix_state | false | configure | blocked | 11 | +| server_not_in_scope † | permanent | false | configure | blocked | 11 | +| tool_denied_by_config | permanent | false | configure | blocked | 11 | +| missing_annotation | permanent | false | configure | blocked | 11 | +| policy_filtered | permanent | false | none | blocked | 11 | +| not_found | permanent | false | configure | unknown_ids | 12 | +| server_not_configured | permanent | false | configure | unknown_ids | 12 | + +† operator tier only; agent-token tier reports `not_found` (byte-indistinguishable from ordinary not_found). + +Reserved (documented, not implemented): `server_saturated`. + +### SetVerdict +`ready` (exit 0) < `degraded_retryable` (10) < `blocked` (11) < `unknown_ids` (12). Worst present wins. + +### Precedence (first match per ID) +server_not_configured → server_not_in_scope† → server_quarantined → server_disabled → not_found → tool_denied_by_config → tool_blocked_by_user → tool_changed → tool_pending_approval → hash_mismatch → oauth_required → server_unhealthy → server_initializing → annotation filters (read_only_only → exclude_destructive → exclude_open_world; per owning filter: nil hint ⇒ missing_annotation, explicit unsafe ⇒ policy_filtered) → ready. + +## Request / Response DTOs (contracts/types.go → generated contracts.ts) + +### PreflightRequest +| Field | Type | Rules | +|---|---|---| +| tools | []PreflightToolRef | 1–100 entries after dedup; empty ⇒ 400 | +| profile | string? | must exist ⇒ else 400 | +| policy | PreflightPolicy? | three optional bools (read_only_only, exclude_destructive, exclude_open_world) | +| wait_ms | int? | 0–10000; >10000 ⇒ 400 | + +### PreflightToolRef +| Field | Type | Rules | +|---|---|---| +| id | string | `server:tool`; malformed ⇒ per-ID not_found with format hint (not request error) | +| pin_hash | string? | `sha256/v{N}:{hex}`; duplicates of same id with different pin ⇒ 400 | + +### PreflightResponse +| Field | Type | Notes | +|---|---|---| +| verdict | SetVerdict | | +| checked_at | RFC3339 | | +| waited_ms | int? | present when wait_ms used | +| tools | []PreflightToolResult | ordered by first occurrence of unique id in request | + +### PreflightToolResult +| Field | Type | Notes | +|---|---|---| +| id | string | echoed requested id | +| status | PreflightStatus | | +| reason | PreflightReason? | unavailable only | +| retryable | bool? | unavailable only | +| action | string? | health-action vocabulary | +| detail | string? | occurrence-specific | +| remediation | string? | one actionable instruction | +| hash | string? | operator tier + ready only | +| did_you_mean | []string? | not_found only; ≤3; caller-visible scope | + +## Evaluator types (internal/preflight) + +- **EvalContext**: `{Index IndexReader, Approvals ApprovalReader, State StateReader, Policy ConfigPolicy, Tier (operator|agent_token), ProfileScope, Filters, Pins map[string]Pin}` — all narrow read interfaces; no transport reachable. +- **Result**: mirrors PreflightToolResult minus serialization concerns. +- **ClassifyTool**: shared classification consumed by evaluator, `classifyServerToolStatus`, `describeGateReason` (D2). + +## Activity record (kind `preflight`) + +`{request_id, ts, ids_count, verdict, reasons: map[code]count, per_tool: [{id, status, reason?}]}` — local activity log only; never exported to telemetry (counts/enum codes would be the only telemetry-safe fields, out of scope v1). + +## State transitions + +None persisted — the evaluator is a pure point-in-time read; wait_ms re-evaluates the same pure function until deadline/ready/non-retryable. diff --git a/specs/098-tools-preflight/plan.md b/specs/098-tools-preflight/plan.md new file mode 100644 index 00000000..46a7c5dc --- /dev/null +++ b/specs/098-tools-preflight/plan.md @@ -0,0 +1,102 @@ +# Implementation Plan: Required-Tools Preflight + +**Branch**: `098-tools-preflight` | **Date**: 2026-08-15 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/098-tools-preflight/spec.md` + +## Summary + +A deterministic, side-effect-free per-tool availability check: one shared eligibility evaluator (new `internal/preflight` package) composes existing local state (Bleve index, spec 032 approval records + hashes, stateview connection snapshot + health classification, config policy, spec 094 annotation classifier) into a `ready`-or-one-of-15-failure-reasons verdict with a fixed precedence chain. Exposed via `POST /api/v1/preflight` (chi route in `internal/httpapi`, profile-aware, wait_ms long-poll under the spec 093 shed budget) and `mcpproxy tools preflight` (Cobra, exit codes 0/10/11/12). Every executed preflight writes an activity record. Zero MCP-surface changes; zero upstream I/O (instrumented-transport asserted); sabotage E2E matrix covers every reason cell. + +## Technical Context + +**Language/Version**: Go 1.24 module toolchain (repo builds with local Go 1.25) +**Primary Dependencies**: existing only — chi (httpapi), bbolt (storage), Bleve (index), zap (logging), Cobra (CLI), swaggo/swag v2 (contract regen). **No new dependencies.** +**Storage**: existing BBolt buckets (read-only for approval records/hashes); no new buckets. Activity log via existing `internal/logs` activity store (new record kind `preflight`). +**Testing**: `go test -race ./internal/...`; handler-level benchmark (`testing.B`); E2E sabotage matrix in `internal/server/e2e_test.go`-style harness with the existing test ctl-server fixtures (DESC_FILE rug-pull trick per QA harness memory); server-edition `go test -tags server`. +**Target Platform**: macOS/Linux/Windows, both editions (personal + server) — feature lives in shared `internal/*`, ships in both automatically. +**Project Type**: single Go project; no frontend changes beyond activity-view tolerance check (records render generically). +**Performance Goals**: p95 < 50 ms for a 10-ID batch at handler level; zero upstream calls (hard assertion). +**Constraints**: byte-identical `tools/list` across all three routing modes; additive-only enum; disclosure tiers (operator vs agent-token); wait_ms ≤ 10 s inside spec 093 shed budget with ≥250 ms poll floor; 503 when runtime unavailable. +**Scale/Scope**: ≤100 IDs per request; evaluator must stay O(ids) with in-memory/BBolt lookups only (constitution: 1,000-tool installs). + +## Constitution Check + +| Principle | Status | Notes | +|---|---|---| +| I. Performance at Scale | PASS | Stat-only local reads; benchmark-gated p95; no blocking of API requests (wait_ms uses context-aware polling, counted in shed budget) | +| II. Actor-Based Concurrency | PASS | Evaluator is a pure read-side function over stateview `Snapshot()` (lock-free) + BBolt reads; no new goroutines except the bounded wait_ms poll loop owned by the request context | +| III. Configuration-Driven | PASS | No new config fields in v1 (deliberate — avoids the 4-point hot-reload checklist); profile comes from the request | +| IV. Security by Default | PASS | Disclosure tiers enforced in the evaluator context; scope-silence byte-indistinguishable `not_found`; quarantine semantics untouched (preflight reports, never bypasses); REST requires API key as everywhere | +| V. TDD | PASS | Taxonomy table drives table-driven unit tests written first; sabotage E2E matrix is the acceptance gate | +| VI. Documentation Hygiene | PASS | FR-017: rest-api.md, CLI docs, new feature page, usage examples; `make swagger` + generate-types regen | + +**Post-design re-check (after Phase 1)**: PASS — no violations introduced; no Complexity Tracking entries needed. + +## Project Structure + +### Documentation (this feature) + +```text +specs/098-tools-preflight/ +├── plan.md # This file +├── research.md # Phase 0: seam analysis + decisions +├── data-model.md # Phase 1: entities, enum, precedence +├── quickstart.md # Phase 1: build → sabotage → verify walkthrough +├── contracts/ +│ └── preflight-api.yaml # OpenAPI fragment for POST /api/v1/preflight +└── tasks.md # Phase 2 (/speckit.tasks) +``` + +### Source Code (repository root) + +```text +internal/preflight/ # NEW package: evaluator + taxonomy +├── reasons.go # reason enum constants, classes, precedence, verdict/exit mapping (single source of truth) +├── evaluator.go # Evaluate(ctx, EvalContext, []ToolRef) -> []Result; composes gate inputs +├── evaluator_test.go # table-driven: every enum cell + precedence co-occurrence pairs +└── bench_test.go # handler-level benchmark (SC-002) + +internal/contracts/types.go # PreflightRequest/Response/ToolResult DTOs + reason constants mirror (generate-types → contracts.ts) +internal/server/mcp.go # dispatch gate consolidation: handleCallToolVariant inline checks delegate to preflight evaluator semantics +internal/server/mcp_visibility.go # describeGateReason reconciled onto shared classification +internal/server/preflight_glue.go # NEW: MCPProxyServer method exposing evaluator with index/stateview/storage/profile wiring (ServerController surface) +internal/httpapi/preflight.go # NEW: POST /api/v1/preflight handler (validation, tiers, wait_ms, activity record) +internal/httpapi/server.go # route registration + ServerController interface method +cmd/mcpproxy/tools_cmd.go # `tools preflight` subcommand (exit codes, -o formats, --pin/--profile/--wait/filter flags) +cmd/mcpproxy/exit_codes.go # typed preflight exit-code error + central classification +internal/storage/activity_models.go # activity type "preflight" (allowlist + metadata shape) +internal/runtime/activity_service.go # synchronous durable RecordPreflight seam +oas/swagger.yaml # regenerated (make swagger) +frontend/src/types/contracts.ts # regenerated (cmd/generate-types) +docs/api/rest-api.md # endpoint reference +docs/cli-management-commands.md # CLI reference + exit codes +docs/features/tools-preflight.md # NEW feature page (concept, taxonomy, recipes) +docs/… # usage-example expansion (FR-017) +``` + +**Structure Decision**: New leaf package `internal/preflight` holds taxonomy + evaluator so both `internal/server` (dispatch reconciliation, glue) and `internal/httpapi` (REST) depend on it without a cycle; `internal/server` remains the only place with index/stateview/storage wiring, exposed to httpapi via one new ServerController method (precedent: `GetToolApprovalStatus`). + +## Key Design Decisions (from research.md) + +1. **Evaluator inputs are injected snapshots, not live managers**: `EvalContext{Index, Approvals, StateSnapshot, ConfigPolicy, Tier, Profile, Filters, Pins}` — makes the zero-upstream-I/O guarantee structural (the evaluator cannot reach a transport) and unit tests trivial. The glue layer in `internal/server` builds the context; `serverToolNames`'s live-ListTools fallback is unreachable by construction. Evaluator infrastructure read errors (Bleve/BBolt/snapshot) surface as an error → handler 503, never a fabricated reason code. +2. **Dispatch consolidation via shared primitives across ALL dispatch paths**: the shared gate primitives (`preflight.ClassifyTool` and friends) are consumed by `handleCallToolVariant`, the direct-mode callability path (`mcp_direct_callability.go`), code_execution, and stored-script dispatch — not just contract-tested. Known divergences fixed: quarantine-enabled/skip flags honored everywhere; `changed` no longer collapsed into `pending`; `auto_approve_tool_changes` ⇒ ready. Dispatch behavior is ground truth; where dispatch is deliberately fail-open (unindexed tool may still be callable), the no-skew guarantee is one-way (spec FR-002). +3. **Reason enum lives once in `internal/preflight/reasons.go`**, mirrored into `internal/contracts` DTOs and added to the `cmd/generate-types` **template source** (the generator emits hard-coded TypeScript — new types must be added to the generator itself, then regenerated); `frontend/src/types/api.ts` activity-kind union updated separately. Anti-drift unit test asserts enum ≡ contracts constants. +4. **`server_initializing` is server-level**: derived from connection state ∈ {Connecting, Discovering, Authenticating} only; no per-tool indexing progress claims (stateview populates post-Bleve). +5. **PendingAuth → `oauth_required`** mapped explicitly before the health-calculator fallthrough. +6. **Activity record**: new type `preflight` written through the real activity seam — `internal/storage` activity models + `internal/runtime` ActivityService — via a new **synchronous durable** `RecordPreflight` path (the async bounded event channel may drop; FR-014 forbids that for preflight). `RequestID` stays first-class; verdict/counts/per-tool go in `ActivityRecord.Metadata`; existing status vocabulary reused. Every duplicated type vocabulary updated: storage allowlist, CLI activity allowlist/rendering, swagger enums, frontend `api.ts` union + filter menu. +7. **wait_ms**: handler-level context-deadline poll loop (floor 250 ms) re-running the evaluator, bounded by a small dedicated preflight-wait semaphore; exhausted semaphore ⇒ degrade to immediate resolve with `waited_ms: 0` (spec 093 admission control is upstream-call-scoped and not reused here — corrected assumption). +8. **Profiles without index mutation**: exact-ID existence = shared index + profile scope filter (per-profile Bleve sub-indexes are a ranking concern; `ForProfile` lazily creates/caches indexes and MUST NOT be called from the read-only preflight path). Agent-token `ProfilePin` is propagated through REST auth (today it is dropped — fix in scope) and evaluation runs under the intersection of token scope ∩ token pin ∩ requested profile. +9. **OAS/generation reality**: `make swagger` generates from Go swag annotations and excludes `specs/` — the contract YAML here is design documentation; the implementation adds swag annotations on the handler/DTOs and validates with `scripts/verify-oas.sh` / `make swagger-verify`. Response uses the standard `APIResponse{data}` envelope + existing security scheme names. +10. **CLI exit codes**: new typed exit-code error recognized by the central classifier in `cmd/mcpproxy` (exit_codes.go / main.go error mapping) + a `cliclient` Preflight method — codes 10/11/12 cannot be returned ad hoc from the subcommand. +11. **Windows**: no special code — named pipe already provides admin context to the CLI; stated in docs. + +## Verification Plan (maps to user requirements) + +- **Local feature verification**: isolated dev instance on high port with scratch `--data-dir` **and `--config`** (per repo convention), fixture upstreams from the QA harness (ctl-server with DESC_FILE rug-pull), walk every sabotage cell live; scripted in quickstart.md. +- **code_execution / stored scripts interaction**: run a code_execution script and a stored script (spec 097) end-to-end on the feature branch; assert unchanged behavior + byte-identical `tools/list` snapshots across the three routing modes; document the REST-from-harness composition pattern. +- **Activity-log transparency**: every sabotage-cell run followed by `mcpproxy activity list --request-id` assertion + Web UI activity view spot-check. +- **Cross-model review**: opencode (gpt-5.6-sol) on the full diff; fix→re-review ≤5 rounds per project cap. + +## Complexity Tracking + +No constitution violations — table intentionally empty. diff --git a/specs/098-tools-preflight/quickstart.md b/specs/098-tools-preflight/quickstart.md new file mode 100644 index 00000000..def45d7b --- /dev/null +++ b/specs/098-tools-preflight/quickstart.md @@ -0,0 +1,76 @@ +# Quickstart: Required-Tools Preflight (098) + +Local verification walkthrough — isolated dev instance per repo convention (high port, scratch `--data-dir` **and** `--config`; never the default ~/.mcpproxy, and don't rely on the e2e script's pkill). + +## 1. Build & start isolated instance + +```bash +go build -o mcpproxy ./cmd/mcpproxy +SCRATCH=$(mktemp -d) +./mcpproxy serve --listen 127.0.0.1:18098 \ + --data-dir "$SCRATCH/data" --config "$SCRATCH/config.json" \ + --log-level=debug & +API_KEY=$(jq -r .api_key "$SCRATCH/config.json") +``` + +Add fixture upstreams (QA harness ctl-server; DESC_FILE enables the rug-pull cell): + +```bash +curl -s -X POST -H "X-API-Key: $API_KEY" localhost:18098/api/v1/servers \ + -d '{"name":"ctl","command":"node","args":["internal/testdata/ctl-server.js"],"protocol":"stdio","enabled":true}' +``` + +## 2. Happy path + +```bash +./mcpproxy tools preflight ctl:echo ctl:add --output json ; echo "exit=$?" +# expect: verdict ready, exit=0, both tools status=ready +curl -s -X POST -H "X-API-Key: $API_KEY" localhost:18098/api/v1/preflight \ + -d '{"tools":[{"id":"ctl:echo"}]}' | jq . +``` + +## 3. Sabotage matrix (each cell → exact reason + exit code) + +| Cell | Induce | Expect | +|---|---|---| +| server_disabled | `mcpproxy upstream disable ctl` | 11 / server_disabled / action enable | +| server_quarantined | quarantine via API | 11 / server_quarantined / approve | +| tool_pending_approval | add new tool on ctl (DESC_FILE) before approval | 11 / tool_pending_approval | +| tool_changed | rewrite tool description via DESC_FILE after approval | 11 / tool_changed | +| tool_blocked_by_user | `POST /servers/ctl/tools/block` | 11 / tool_blocked_by_user | +| tool_denied_by_config | add `disabled_tools:["ctl:echo"]` | 11 / tool_denied_by_config | +| oauth_required | fixture server in PendingAuth | 11 / oauth_required / login | +| server_unhealthy | SIGSTOP the ctl child | 10 / server_unhealthy | +| server_initializing | preflight immediately after enable | 10 / server_initializing | +| hash_mismatch | `--pin ctl:echo=sha256/v2:deadbeef` | 11 / hash_mismatch | +| missing_annotation | `--read-only-only` against ctl (no annotations) | 11 / missing_annotation | +| policy_filtered | `--exclude-destructive` vs tool with destructiveHint=true | 11 / policy_filtered | +| not_found | `ctl:nope` | 12 / not_found (+did_you_mean) | +| server_not_configured | `ghost:echo` | 12 / server_not_configured | +| scope tiers | profile excluding ctl: operator → server_not_in_scope; agent token → not_found | 11 vs 12, byte-indistinguishable not_found | + +## 4. Transparency check + +```bash +RID=$(curl -si -X POST -H "X-API-Key: $API_KEY" localhost:18098/api/v1/preflight \ + -d '{"tools":[{"id":"ctl:echo"}]}' | awk -F': ' '/X-Request-Id/{print $2}' | tr -d '\r') +./mcpproxy activity list --request-id "$RID" # expect kind=preflight record with verdict +``` + +Open Web UI → Activity: the preflight record renders with its verdict. + +## 5. MCP-surface & code-exec non-regression + +```bash +go test ./internal/server -run ToolsListSnapshot # byte-identical across 3 routing modes +# run a code_execution script + a stored script (spec 097) against the instance; behavior unchanged +``` + +## 6. Full gates before push + +```bash +go test -race ./internal/... && go test -tags server ./internal/serveredition/... -race +./scripts/test-api-e2e.sh +/opt/homebrew/bin/golangci-lint run --config .github/.golangci.yml ./... +make swagger && git diff --exit-code oas/ frontend/src/types/contracts.ts +``` diff --git a/specs/098-tools-preflight/research.md b/specs/098-tools-preflight/research.md new file mode 100644 index 00000000..518f17ef --- /dev/null +++ b/specs/098-tools-preflight/research.md @@ -0,0 +1,67 @@ +# Phase 0 Research: Required-Tools Preflight + +Full external research (19 gateways, 16 papers, 11 infra patterns, client-side survey) and the decision log live in the research artifact "Required-Tools Preflight — Research & Alternatives (mcpproxy #969)", verified 2026-08-15. This file records the codebase-seam decisions that drive the plan. No NEEDS CLARIFICATION items remain — all product decisions were locked 2026-08-15 and reporter-confirmed 2026-08-13 (issue #969). + +## D1. Where does the evaluator live? + +- **Decision**: New leaf package `internal/preflight` (taxonomy + pure evaluator), glue in `internal/server/preflight_glue.go`, REST in `internal/httpapi/preflight.go`. +- **Rationale**: `internal/server` owns index/stateview/storage wiring; `internal/httpapi` talks to it via the `ServerController` interface (precedent: `GetToolApprovalStatus`). A leaf package avoids an httpapi→server import cycle and keeps the evaluator free of transports (structural zero-I/O). +- **Alternatives**: methods on `MCPProxyServer` only (rejected: untestable without full server; enum would have no single home); `internal/contracts` (rejected: contracts is DTO-only by convention). + +## D2. How are the three disagreeing gates consolidated? + +Existing gates and their known divergences (verified against source, 2026-08): + +| Gate | Location | Divergence | +|---|---|---| +| Dispatch inline checks | `handleCallToolVariant` (internal/server/mcp.go ~2032–2074) | Ground truth; honors quarantine-enabled/skip flags; emits `emitActivityPolicyDecision` | +| `describeGateReason` | internal/server/mcp_visibility.go ~112 | Applies pending/changed gate only when quarantine enabled && not skipped — matches dispatch | +| `classifyServerToolStatus` | internal/server/mcp.go ~5673 | Checks approval **unconditionally** (FP source for `auto_approve_tool_changes` servers) and collapses `changed` → `pending_approval` | + +- **Decision**: Introduce `preflight.ClassifyTool(inputs)` as the single classification; refactor `classifyServerToolStatus` and `describeGateReason` to delegate; dispatch keeps its own code path but a contract test asserts dispatch refusal ⇔ non-ready classification for every evaluator-represented gate (FR-002). Dispatch behavior wins on every divergence: quarantine flags honored, `changed` distinct from `pending`, auto-approved changes = ready. +- **Alternatives**: literal single call site for dispatch too (rejected for v1: dispatch path is latency-sensitive and interleaved with arg validation; semantic equivalence + contract test achieves no-skew with less blast radius). + +## D3. State sources per reason code + +| Reason | Source | +|---|---| +| `server_not_configured` | config: server name absent | +| `server_not_in_scope` | profile scope (`serverInScope`/profile.Allows) — operator tier only | +| `server_quarantined` | ServerConfig.Quarantined | +| `server_disabled` | ServerConfig.Enabled == false | +| `not_found` | Bleve `GetToolsByServer`/lookup miss (server known, tool absent) — **no** live ListTools fallback | +| `tool_denied_by_config` | `isToolConfigDenied` (enabled_tools/disabled_tools) | +| `tool_blocked_by_user` | ToolApprovalRecord.Disabled | +| `tool_changed` | ToolApprovalRecord.Status == changed (when quarantine gates apply) | +| `tool_pending_approval` | ToolApprovalRecord.Status == pending (incl. scan-hold) | +| `hash_mismatch` | pin vs ToolApprovalRecord.CurrentHash (+ HashSchemaVersion match) | +| `oauth_required` | connection state PendingAuth (explicit map, before health fallthrough) | +| `server_unhealthy` | connection state Error/Disconnected via stateview snapshot; detail/diagnostic from Spec 044 classifier when present | +| `server_initializing` | connection state Connecting/Discovering/Authenticating — server-level only | +| `missing_annotation` / `policy_filtered` | spec 094 `excludeReason` per named ID under caller filters | + +## D4. Pin format + +- **Decision**: `sha256/v{HashSchemaVersion}:{hex}` (e.g. `sha256/v2:9f2c41ab…`). A pin whose schema version differs from the record's `HashSchemaVersion` reports `hash_mismatch` with detail "hash schema changed (proxy upgrade); relock", distinguishable from genuine drift in `detail` while remaining one reason code. +- **Rationale**: proxy upgrades can re-version the hash algorithm (backfill exists in tool_quarantine.go); pins must not silently rot into false drift alarms. + +## D5. did_you_mean + +- **Decision**: nearest-name via prefix + Levenshtein ≤2 over the **caller-visible** indexed tool names (scope- and tier-filtered before matching; quarantined-server names excluded). Max 3 suggestions. New small helper in `internal/preflight`; `lookupIndexedTool` is exact-match only and not reused for this. + +## D6. Activity record + +- **Decision**: reuse the existing activity-log storage with a new record kind `preflight`; payload `{ids_count, verdict, reasons{code:count}, per_tool[{id,status,reason?}]}`, request-ID correlated. CLI `activity list` and the Web UI render unknown kinds generically today (verified in list rendering paths) — plus a small type-label addition for polish. +- **Alternatives**: separate preflight log bucket (rejected: breaks the one-place transparency story). + +## D7. wait_ms & spec 093 shed budget + +- **Decision**: handler-level poll loop (floor 250 ms) on the request context, deadline = min(wait_ms, 10 000 ms); the request is registered with the existing concurrency-shed accounting exactly like other long requests; early-terminate on first non-retryable failure. + +## D8. tools/list byte-identity guard + +- **Decision**: snapshot test that serializes registered tool schemas for all three routing modes and compares against committed goldens (also closes the pre-existing drift-test gap flagged in the research: routing modes already silently lack some default-surface params — goldens capture current state, not fix it, to keep this feature zero-MCP-change). + +## Resolved: all Technical Context unknowns + +None outstanding — stack, storage, testing, platforms fixed by repo conventions; product contract fixed by the decision log. diff --git a/specs/098-tools-preflight/spec.md b/specs/098-tools-preflight/spec.md index 95642ba9..a47a2c4a 100644 --- a/specs/098-tools-preflight/spec.md +++ b/specs/098-tools-preflight/spec.md @@ -39,7 +39,7 @@ A platform engineer's harness (n8n, GitHub Actions, a Python script) calls `POST 1. **Given** a valid API key and a list of tool IDs, **When** POSTing to `/api/v1/preflight`, **Then** the response contains `verdict`, `checked_at`, and one result per requested unique ID carrying `id` and `status`; `unavailable` results additionally carry `reason`, `retryable`, `action`, `detail`, `remediation` (ready results omit failure fields). 2. **Given** a `pin_hash` that no longer matches the tool's current schema hash, **When** the preflight runs, **Then** that tool reports `hash_mismatch` (blocked class), even though the tool is otherwise ready. -3. **Given** a `profile` parameter, **When** the preflight runs, **Then** verdicts reflect that profile's server scope and per-profile index — a tool outside the profile reports `not_found`, matching what a profile-pinned session would experience. +3. **Given** a `profile` parameter, **When** the preflight runs, **Then** verdicts reflect that profile's server scope — at the operator tier a tool outside the profile reports `server_not_in_scope` with a `detail` noting that a session under this profile sees `not_found`; at the agent-token tier it reports plain `not_found`. Exact-ID existence is evaluated against the shared index with the profile scope applied (per-profile search indexes are a ranking concern, irrelevant to exact-ID checks, and are never created or mutated by a preflight). 4. **Given** `wait_ms` is supplied and a failure is retryable-only, **When** the state becomes ready within the deadline, **Then** the response reports `ready`; **When** the deadline passes first, **Then** the response resolves (never hangs) with the current reasons. 5. **Given** an agent-token caller whose scope excludes a requested server, **When** the preflight runs, **Then** the out-of-scope tool reports plain `not_found` (no existence leak, no cross-scope `did_you_mean`, no hashes), while the same request with the operator API key reports `server_not_in_scope` with the full diagnosis. @@ -94,12 +94,12 @@ An agent operating through mcpproxy's MCP surfaces (retrieve_tools mode, code_ex ### Functional Requirements - **FR-001 (Evaluator)**: System MUST provide a single shared eligibility evaluator that, given a tool ID and a caller context (auth tier, optional profile, optional policy filters, optional pin hash), returns either `ready` or exactly one failure reason from the closed enum (FR-003). All preflight surfaces MUST use this evaluator. -- **FR-002 (No-skew)**: The evaluator's verdict semantics MUST be consistent with call-time dispatch gating **for the gates the evaluator represents** (existence, scope, quarantine, approval status, tool/user/config disablement): any such state that causes dispatch to refuse a tool MUST map to a non-`ready` preflight verdict, and the existing per-tool gates (dispatch inline checks, describe-gate reasons, locked-tool classification) MUST be reconciled so they cannot disagree with the evaluator. Divergences found during consolidation MUST be resolved in favor of dispatch behavior (dispatch is ground truth) and covered by tests. Explicit carve-out: connection-time failures that only manifest during a live call (network races, per-user OAuth state in the server edition) are outside the no-skew guarantee — preflight is a point-in-time eligibility check, not a call-success guarantee. -- **FR-003 (Reason enum)**: A per-tool result has `status: ready | unavailable`. `ready` is the success status, not a failure reason. For `unavailable`, the closed v1 failure-reason enum is exactly 15 codes, per the normative table below. Each result carries `id` (the requested unique ID), `status`, and for failures `reason`, `retryable` (bool), `action` (existing health-action vocabulary: `login`/`restart`/`enable`/`approve`/`view_logs`/`set_secret`/`configure`/`none`), `detail`, `remediation`. Evolution is additive-only; consumers are instructed to treat unknown codes as non-retryable. `server_saturated` is reserved (not implemented). `server_not_in_scope` is emitted at the operator tier only; the agent-token tier maps that state to `not_found` (see FR-013). +- **FR-002 (No-skew)**: The evaluator's verdict semantics MUST be consistent with call-time dispatch gating **for the gates the evaluator represents** (existence, scope, quarantine, approval status, tool/user/config disablement): any such state that causes dispatch to refuse a tool MUST map to a non-`ready` preflight verdict, and the existing per-tool gates (dispatch inline checks, describe-gate reasons, locked-tool classification) MUST be reconciled so they cannot disagree with the evaluator. Divergences found during consolidation MUST be resolved in favor of dispatch behavior (dispatch is ground truth) and covered by tests. Explicit carve-out: connection-time failures that only manifest during a live call (network races, per-user OAuth state in the server edition) are outside the no-skew guarantee — preflight is a point-in-time eligibility check, not a call-success guarantee. Directionality: for gates where dispatch is deliberately fail-open (e.g. an unindexed tool may still be callable), the guarantee is one-way — dispatch refusal ⇒ non-ready preflight; a non-ready preflight does not imply dispatch would refuse. Exact two-way equivalence is required only for the shared policy gates (quarantine, approval, user/config disablement). All dispatch paths (call_tool variants, direct mode, code_execution, stored scripts) MUST consume the same shared gate primitives. +- **FR-003 (Reason enum)**: A per-tool result has `status: ready | unavailable`. `ready` is the success status, not a failure reason. For `unavailable`, the closed v1 failure-reason enum is exactly 15 codes, per the normative table below. Each result carries `id` (the requested unique ID), `status`, and for failures `reason`, `retryable` (bool), `action` (existing health-action vocabulary: `login`/`restart`/`enable`/`approve`/`view_logs`/`set_secret`/`configure`; "no action" is represented by **omitting** the field, matching the existing health constants where none = empty string), `detail`, `remediation`. Evolution is additive-only; consumers are instructed to treat unknown codes as non-retryable. `server_saturated` is reserved (not implemented). `server_not_in_scope` is emitted at the operator tier only; the agent-token tier maps that state to `not_found` (see FR-013). | Reason | Class | `retryable` | Default `action` | Set verdict | CLI exit | |---|---|---|---|---|---| - | `server_initializing` | retryable | true | none | degraded_retryable | 10 | + | `server_initializing` | retryable | true | — (omitted) | degraded_retryable | 10 | | `server_unhealthy` | retryable | true | best-effort from diagnostics (`restart`/`login`/`view_logs`; default `view_logs`) | degraded_retryable | 10 | | `server_disabled` | fix-state-first | false | enable | blocked | 11 | | `server_quarantined` | fix-state-first | false | approve | blocked | 11 | @@ -111,7 +111,7 @@ An agent operating through mcpproxy's MCP surfaces (retrieve_tools mode, code_ex | `server_not_in_scope` (operator tier only) | permanent-config | false | configure | blocked | 11 | | `tool_denied_by_config` | permanent-config | false | configure | blocked | 11 | | `missing_annotation` | permanent-config | false | configure | blocked | 11 | - | `policy_filtered` | permanent-config | false | none | blocked | 11 | + | `policy_filtered` | permanent-config | false | — (omitted) | blocked | 11 | | `not_found` | permanent-config | false | configure | unknown_ids | 12 | | `server_not_configured` | permanent-config | false | configure | unknown_ids | 12 | @@ -120,13 +120,13 @@ An agent operating through mcpproxy's MCP surfaces (retrieve_tools mode, code_ex - **FR-005 (State sources)**: Verdicts MUST be computed exclusively from local state: index existence, tool-approval records and hashes (spec 032), connection-state snapshot and health classification, config policy (enabled/disabled tools), and annotation classification (spec 094 `excludeReason`) when policy filters are supplied. `server_initializing` is a server-level verdict; the system MUST NOT claim per-tool knowledge ("your tool will appear") during discovery/indexing. - **FR-006 (Zero upstream I/O)**: A preflight MUST perform zero upstream server calls and MUST NOT mutate proxy runtime state (no connects, reconnects, re-index, config or approval changes). "Side-effect-free" is defined as no upstream I/O and no runtime mutation; the local activity-log write required by FR-014 is explicitly permitted and expected. Any helper with a live-call fallback MUST be excluded or guarded. Asserted by an automated test using an instrumented transport. If the runtime is unavailable when a preflight arrives (degraded process state), the endpoint MUST refuse with 503 (`preflight unavailable`) rather than emit reduced-fidelity verdicts; storage-only evaluation exists only inside unit tests, never on the served surface. - **FR-007 (PendingAuth)**: The PendingAuth/deferred-OAuth connection state MUST map to `oauth_required` with `retryable: false` and action `login` (waiting does not help without a login). -- **FR-008 (REST surface)**: `POST /api/v1/preflight` MUST accept `{tools: [{id, pin_hash?}…], profile?, policy?{read_only_only, exclude_destructive, exclude_open_world}, wait_ms?}` and return `{verdict, checked_at, waited_ms?, tools: [per-ID results]}` with HTTP 200 whenever the check executed (validation errors: 400; runtime unavailable: 503). Set-level `verdict` per the FR-003 table. Every result carries its requested `id`; results are ordered by first occurrence of each unique ID in the request. Duplicate IDs are deduplicated; duplicates carrying **different** `pin_hash` values are a validation error (400). Requests rejected with 400/503 did not execute a preflight and write no preflight activity record (standard HTTP request handling applies). +- **FR-008 (REST surface)**: `POST /api/v1/preflight` MUST accept `{tools: [{id, pin_hash?}…], profile?, policy?{read_only_only, exclude_destructive, exclude_open_world}, wait_ms?}` and return `{verdict, checked_at, waited_ms?, tools: [per-ID results]}` with HTTP 200 whenever the check executed (validation errors: 400; runtime unavailable, evaluator infrastructure failure — index/storage/snapshot read errors that cannot be honestly mapped to a reason code — **or activity-record persistence failure** (FR-014's durable write could not complete, so a 200 would violate the transparency guarantee): 503). The response follows the repo's standard `APIResponse{data: …}` envelope and existing API-key security schemes. Set-level `verdict` per the FR-003 table. Every result carries its requested `id`; results are ordered by first occurrence of each unique ID in the request. The 100-entry limit applies to the **raw** `tools` array (before dedup); duplicate IDs are then deduplicated; duplicates carrying **different** `pin_hash` values are a validation error (400). Requests rejected with 400/503 did not execute a preflight and write no preflight activity record (standard HTTP request handling applies). - **FR-009 (CLI surface)**: `mcpproxy tools preflight … [--profile P] [--pin id=hash] [--read-only-only] [--exclude-destructive] [--exclude-open-world] [--wait duration] [-o json|yaml|table]` MUST wrap the endpoint with exit codes: 0 all ready · 10 degraded-retryable · 11 blocked (operator action) · 12 unknown ID present; worst class wins. Transport/other CLI failures use the existing general exit code 1. The command MUST follow existing CLI output conventions (`-o`, `MCPPROXY_OUTPUT`, `--help-json`). - **FR-010 (Profiles)**: With `profile` supplied, evaluation MUST run under that profile's server scope and per-profile index so the verdict matches a profile-pinned session's experience. Without it, evaluation is the unscoped operator view (documented). Unknown profile: 400. - **FR-011 (Hash pinning)**: With `pin_hash` supplied for an ID, a divergence from the tool's current stored hash MUST report `hash_mismatch`. The pin format MUST embed the hash schema version so proxy-side hash-algorithm bumps are distinguishable from genuine upstream drift; current hashes at the current schema version MUST be discoverable via existing surfaces so pins can be created. -- **FR-012 (wait_ms)**: With `wait_ms > 0` (cap 10 000), when all current failures are retryable-class the system MUST poll local state until every tool is ready or the deadline passes; it terminates early the moment any non-retryable failure appears (waiting cannot help), MUST always resolve with current reasons at the deadline (never hang), MUST use a polling floor (≥250 ms), and the waiting request MUST count against the spec 093 concurrency-shed budget. +- **FR-012 (wait_ms)**: With `wait_ms > 0` (cap 10 000), when all current failures are retryable-class the system MUST poll local state until every tool is ready or the deadline passes; it terminates early the moment any non-retryable failure appears (waiting cannot help), MUST always resolve with current reasons at the deadline (never hang), and MUST use a polling floor (≥250 ms). Waiting capacity is bounded by a dedicated preflight-wait semaphore (small fixed limit); when the semaphore is exhausted the request degrades gracefully — it resolves immediately with current verdicts and `waited_ms: 0` instead of queuing or failing. (Amends the earlier decision-log assumption of a generic spec 093 HTTP shed budget, which does not exist; spec 093 admission control is scoped to upstream tool calls.) - **FR-013 (Disclosure tiers)**: Operator tier (API key / socket / named pipe): full results including hashes and the `server_not_in_scope` diagnosis (emitted when a supplied `profile` — or, in a later phase, a named token scope — excludes an existing server). Agent-token tier: scope-silence — an out-of-scope ID's **entire result** MUST be byte-indistinguishable from an ordinary `not_found` (same `reason`, `retryable`, `action`, `detail`, `remediation` wording; no hashes; no `did_you_mean` crossing the scope boundary). `did_you_mean` is a nearest-name suggestion on `not_found`, computed over the caller-visible index only and never suggesting quarantined-tool names. -- **FR-014 (Activity log)**: Every executed preflight (i.e., any request answered 200) MUST write an activity record carrying the request ID, requested-ID count, set-level verdict, and per-tool reason codes, correlated via X-Request-Id and browsable via `mcpproxy activity list` and the Web UI activity view. Records MUST NOT leak tool names to any telemetry surface (activity log is local-only; telemetry, if any, carries counts and enum codes only). +- **FR-014 (Activity log)**: Every executed preflight (i.e., any request answered 200) MUST write an activity record **synchronously and durably before the 200 is returned** (the existing async activity event channel is bounded and may drop under load, which would violate this guarantee; the preflight write path must not be droppable) carrying the request ID, requested-ID count, set-level verdict, and per-tool reason codes, correlated via X-Request-Id and browsable via `mcpproxy activity list` and the Web UI activity view. Records MUST NOT leak tool names to any telemetry surface (activity log is local-only; telemetry, if any, carries counts and enum codes only). - **FR-015 (MCP surface untouched)**: No MCP tool schema changes in any routing mode: `tools/list` payloads MUST be byte-identical to the prior release across default, retrieve_tools, and code_execution modes. Verified by a snapshot test. - **FR-016 (Sabotage test matrix)**: E2E tests MUST deliberately induce each reason state (quarantine flip, tool-definition drift, tool block, config denial, server disable, server kill/disconnect, mid-indexing, missing/explicit annotations under filters, unknown ID, unknown server, hash mismatch, deferred OAuth, out-of-scope under a profile at both disclosure tiers) and assert the exact reason code, `retryable` flag, and action per cell. Adding an enum code without its cell MUST fail review/CI. - **FR-017 (Docs)**: Documentation MUST be updated: REST API reference (endpoint + schema), CLI reference (command + exit codes), a feature page for preflight (concept, taxonomy table, cron/CI recipes, agent-workflow examples), and expanded usage examples covering token-saving discovery flows and common agent actions with mcpproxy connected, including how preflight composes with code_execution and stored scripts (via REST from the harness; in-band check mode is Phase 2). diff --git a/specs/098-tools-preflight/tasks.md b/specs/098-tools-preflight/tasks.md new file mode 100644 index 00000000..372d0e46 --- /dev/null +++ b/specs/098-tools-preflight/tasks.md @@ -0,0 +1,73 @@ +# Tasks: Required-Tools Preflight + +**Input**: Design documents from `/specs/098-tools-preflight/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/preflight-api.yaml, quickstart.md +**Convention**: TDD — each implementation task lands with (or after) its failing test. Revised 2026-08-15 after cross-model plan review (opencode/gpt-5.6-sol): activity via storage+runtime seam (synchronous), profiles before route, no ForProfile use, ProfilePin propagation, swag-annotation OAS path, generator-source contracts.ts, dedicated wait semaphore, goldens from merge-base, dispatch primitives across all four dispatch paths. + +## Phase 1: Setup + +- [ ] T001 Create `internal/preflight` package skeleton (`reasons.go`, `evaluator.go`, doc comment stating zero-I/O invariant and the error-not-fabricated-reason rule) per plan.md structure +- [ ] T002 [P] Capture tools/list golden snapshots for all three routing modes **from merge-base (origin/main)** into `internal/server/testdata/toolslist_goldens/` + snapshot test `internal/server/toolslist_snapshot_test.go` comparing the feature branch byte-for-byte (FR-015; goldens must predate any dispatch refactor) + +## Phase 2: Foundational (blocking all stories) + +- [ ] T003 Define the 15-code reason enum, classes, retryable defaults, action mapping (action omitted = ""), precedence chain, set-verdict + exit-code mapping in `internal/preflight/reasons.go` as single source of truth (data-model tables), with table-driven test `internal/preflight/reasons_test.go` asserting the full FR-003 table +- [ ] T004 Define narrow read interfaces + `EvalContext`/`Result` types in `internal/preflight/evaluator.go` (`IndexReader`, `ApprovalReader`, `StateReader`, `ConfigPolicy`, tier, profile scope, filters, pins); `Evaluate` returns `([]Result, error)` — infra read errors are errors, never reason codes +- [ ] T005 Extract the spec 094 annotation classifier (`excludeReason` logic) from `internal/server/mcp_annotations.go` into a shared lower-level package (`internal/toolannotations`) with `internal/server` delegating; unit tests moved/extended (unexported-symbol cycle fix, review finding 14) +- [ ] T006 Implement `ClassifyTool` shared classification in `internal/preflight` (quarantine-enabled/skip flags honored; `changed` distinct from `pending`; `auto_approve_tool_changes` ⇒ ready) with unit tests covering the documented divergences (research D2) +- [ ] T007 Implement `Evaluate` walking the FR-004 precedence chain, incl. PendingAuth→`oauth_required` explicit map, server-level `server_initializing`, pin check (`sha256/v{N}:{hex}`, schema-version aware), annotation slot via `internal/toolannotations`; table-driven tests: every enum cell + co-occurrence pairs per adjacent precedence pair in `internal/preflight/evaluator_test.go` +- [ ] T008 Implement scope/tier disclosure: agent-token tier out-of-scope ⇒ byte-indistinguishable `not_found` (serialized-bytes comparison test); operator tier ⇒ `server_not_in_scope` with profile-session detail; profile semantics = shared index existence + profile scope filter, **no `ForProfile` calls** (FR-010/FR-013, review findings 9/10/13) +- [ ] T009 [P] Implement `did_you_mean` helper (prefix + Levenshtein ≤2, ≤3 suggestions, caller-visible names only, quarantined-server names excluded) in `internal/preflight/suggest.go` + tests +- [ ] T010 Propagate agent-token `ProfilePin` through REST auth into the evaluation context (`internal/auth/agent_token.go`, `internal/httpapi/server.go`); evaluation scope = token scope ∩ token pin ∩ requested profile; tests for each intersection case (review finding 11) +- [ ] T011 Mirror DTOs + reason constants into `internal/contracts/types.go` per data-model.md; add the new types to the `cmd/generate-types` **generator source** and regen `frontend/src/types/contracts.ts`; anti-drift unit test (preflight enum ≡ contracts constants) +- [ ] T012 Glue: `internal/server/preflight_glue.go` — build EvalContext from index manager, storage, stateview snapshot, config, resolved profile scope; expose `RunPreflight` via ServerController (`internal/httpapi/server.go`); explicitly no `serverToolNames` (live-ListTools fallback); test with instrumented transport asserting zero upstream calls **plus before/after snapshots of runtime/index/config/approval state proving no mutation** (FR-006, review finding 17) +- [ ] T013 Dispatch consolidation onto shared primitives across all four paths: `handleCallToolVariant` (internal/server/mcp.go), direct-mode callability (`internal/server/mcp_direct_callability.go`), code_execution dispatch, stored-script dispatch; refactor `classifyServerToolStatus` + `describeGateReason` to delegate; FR-002 contract tests: two-way equivalence for shared policy gates, one-way (refusal ⇒ non-ready) for fail-open existence gates, covering `auto_approve_tool_changes` and quarantine-skip cases (review findings 1/2/3) +- [ ] T014 Activity seam: add activity type `preflight` to `internal/storage/activity_models.go` allowlist with Metadata payload `{verdict, reasons{code:count}, per_tool[{id,status,reason?}]}` (RequestID first-class, existing status vocabulary); add synchronous durable `RecordPreflight` to `internal/runtime/activity_service.go` (bypasses the bounded async channel); storage + service unit tests incl. write-failure propagation (FR-014, review findings 4/5/15) + +## Phase 3: User Story 1 — CLI preflight for cron/CI (P1) 🎯 MVP + +**Goal**: `mcpproxy tools preflight` exits 0/10/11/12 with per-tool verdicts; endpoint + activity land together. +**Independent test**: quickstart §2–§3 CLI cells against isolated instance. + +- [ ] T015 [US1] REST handler `internal/httpapi/preflight.go` with swag annotations (standard `APIResponse{data}` envelope, existing security schemes): validation (empty list 400, raw >100 entries 400, conflicting duplicate pins 400, unknown profile 400, wait_ms range 400, runtime-unavailable + evaluator infra error 503), dedup preserving first-occurrence order, tier detection (API key/socket/pipe vs agent token), **synchronous `RecordPreflight` before every 200**; route registration; handler tests for every 400/503 rule and the no-record-on-reject rule (FR-008/FR-014; review findings 6/17/18/20) +- [ ] T016 [US1] wait_ms poll loop in handler (floor 250 ms, cap 10 s, early-terminate on non-retryable, always resolves at deadline) bounded by a dedicated preflight-wait semaphore (exhausted ⇒ immediate resolve, `waited_ms: 0`); tests incl. deadline, early-termination, semaphore-exhausted degrade (FR-012, review finding 8) +- [ ] T017 [US1] Typed preflight exit-code error + central classification in `cmd/mcpproxy/exit_codes.go` + `main.go` error mapping; `cliclient` Preflight method; tests (review finding 21) +- [ ] T018 [US1] CLI `tools preflight` subcommand in `cmd/mcpproxy/tools_cmd.go`: args = tool IDs, flags `--profile`, `--pin id=hash` (repeatable), `--read-only-only`, `--exclude-destructive`, `--exclude-open-world`, `--wait`, `-o json|yaml|table` + `MCPPROXY_OUTPUT`; exit codes 0/10/11/12 worst-class-wins, transport errors exit 1; `--help-json` metadata; unit tests for exit-code precedence (12>11>10), env output, formats (FR-009) +- [ ] T019 [US1] Benchmarks: evaluator micro-benchmark in `internal/preflight/bench_test.go` + normative handler-level benchmark (incl. response encoding + activity-record build) in `internal/httpapi/preflight_bench_test.go`; SC-002 asserted as committed benchmark with generous CI threshold, not a brittle wall-clock gate (review findings 16/25) + +## Phase 4: User Story 2 — REST harness extras (P1) + +- [ ] T020 [US2] Hash-pin authoring surface: expose approval `CurrentHash` + `HashSchemaVersion` as `sha256/v{N}:{hex}` on the operator-tier per-tool REST payload and `tools list -o json` (explicit contract change; generator + swagger + disclosure tests — never exposed to agent-token tier) (FR-011, review finding 22) +- [ ] T021 [US2] OAS: `make swagger` regen from annotations, `scripts/verify-oas.sh` + `make swagger-verify` clean; server-edition gates: `go build -tags server ./cmd/mcpproxy`, `go test -tags server ./internal/serveredition/... -race`, `golangci-lint --build-tags server` (review findings 6/14-oas) + +## Phase 5: User Story 3 — Activity browsability (P2) + +- [ ] T022 [US3] CLI `activity list` renders type `preflight` (allowlist + verdict summary from Metadata) in `cmd/mcpproxy/activity_cmd.go`; tests (review finding 16) +- [ ] T023 [P] [US3] Frontend: extend activity type union/filter menu in `frontend/src/types/api.ts` + activity view rendering of preflight verdict; Playwright web-ui verification per docs/development/web-ui-verification.md (review finding 16) + +## Phase 6: User Story 4 — Non-regression (P3) + +- [ ] T024 [US4] Golden snapshot test from T002 green on the finished branch (byte-identical tools/list across all three modes) (FR-015) +- [ ] T025 [US4] E2E: run a `code_execution` script and a stored script (spec 097) against the isolated instance; assert unchanged behavior and that dispatch decisions for sabotaged tools agree with preflight verdicts on all four dispatch paths (no-skew live check) + +## Phase 7: Sabotage E2E matrix (acceptance gate) + +- [ ] T026 Committed scenario-keyed matrix (`internal/server/testdata/preflight_sabotage_matrix.json`: scenario → expected {reason, retryable, action}) + E2E `internal/server/preflight_e2e_test.go` driving ctl-server fixtures (DESC_FILE rug-pull): quarantine flip, tool-definition drift, tool block, config denial, server disable, SIGSTOP/kill, mid-indexing, missing/explicit annotation per each of the three filters, unknown ID, unknown server, hash mismatch + schema-version-bump variant, PendingAuth, profile out-of-scope at both tiers; independent assertions per row PLUS reflection check that every enum code appears in ≥1 row; after each cell, `activity list --request-id` lookup asserts the preflight record (FR-016, SC-005; review findings 23/22-activity) +- [ ] T027 Scripted incident-diagnosis scenario (SC-006): tool quarantined between runs → preflight names `tool_changed`/`server_quarantined` in ≤1 step; committed as an E2E assertion + +## Phase 8: Docs & Polish + +- [ ] T028 [P] `docs/api/rest-api.md`: endpoint reference (envelope, tiers, 400/503 rules, wait semantics) (FR-017) +- [ ] T029 [P] `docs/cli-management-commands.md`: `tools preflight` reference with exit-code table and cron/CI recipe (FR-017) +- [ ] T030 [P] NEW `docs/features/tools-preflight.md`: concept, taxonomy + precedence tables, disclosure tiers, transparency/activity story, cron + GitHub Actions + n8n recipes, composition with code_execution/stored scripts (REST-from-harness pattern), Phase-2+ roadmap (FR-017) +- [ ] T031 [P] Usage-examples expansion: README/docs agent-workflow examples — token-saving discovery flow, typical agent actions through mcpproxy, preflight-gated automation example (FR-017) +- [ ] T032 Full gates: `go test -race ./...` (incl. cmd/ CLI tests), server-edition build+test+lint (T021 set), `./scripts/test-api-e2e.sh`, golangci-lint v2 `.github/.golangci.yml`, swagger + generate-types diff-clean +- [ ] T033 Cross-model review of the full diff (opencode gpt-5.6-sol), fix→re-review ≤5 rounds; then quickstart walkthrough end-to-end on the isolated instance + +## Dependencies + +Phase 2 strictly ordered: T003→T004→T005→T006→T007→T008→(T009 [P] anytime after T003)→T010→T011→T012→T013→T014. US1: T015 needs T012+T014 (activity lands with handler); T016–T018 need T015; T019 last in US1. US2/US3 after T015, mutually parallel. T024/T025 after T013+T015. T026–T027 after US1+US2. Docs [P] anytime after US1; T032–T033 last. + +## Implementation strategy + +MVP = Phases 1–3 (evaluator + gates consolidation + REST(+activity) + CLI). Then US2 (pins/OAS), US3 (browsability), US4, sabotage matrix as acceptance gate, docs, gates, cross-model review. From c52e3261d88a20f780af1d263b91d53ddac80220 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 15 Aug 2026 20:46:50 +0300 Subject: [PATCH 3/9] feat(preflight): eligibility evaluator, 15-code taxonomy, toolannotations extraction (098) Related #969 internal/preflight: locked reason enum + precedence chain + exit mapping, precedence-walking evaluator (existence = index OR approval record; connection verdicts outrank not_found on non-Ready servers), tier disclosure with byte-indistinguishable scope-silence (incl. server_not_configured masking at the token tier), schema-versioned hash pins, scope-filtered did_you_mean. internal/toolannotations extracted from mcp_annotations (spec-094 behavior byte-identical). Agent-token ProfilePin propagated via AuthContext(); contracts DTOs + generator. --- cmd/generate-types/main.go | 88 +++ internal/auth/agent_token.go | 19 + internal/auth/agent_token_test.go | 40 ++ internal/contracts/types.go | 129 ++++ internal/preflight/bench_test.go | 157 +++++ internal/preflight/classify.go | 153 +++++ internal/preflight/classify_test.go | 182 ++++++ internal/preflight/contracts_drift_test.go | 76 +++ internal/preflight/doc.go | 30 + internal/preflight/evaluator.go | 617 ++++++++++++++++++ internal/preflight/evaluator_test.go | 573 ++++++++++++++++ internal/preflight/fakes_test.go | 216 ++++++ internal/preflight/reasons.go | 293 +++++++++ internal/preflight/reasons_test.go | 181 +++++ internal/preflight/request.go | 58 ++ internal/preflight/scope.go | 162 +++++ internal/preflight/scope_test.go | 168 +++++ internal/preflight/suggest.go | 123 ++++ internal/preflight/suggest_test.go | 104 +++ internal/preflight/tier_test.go | 192 ++++++ internal/toolannotations/toolannotations.go | 99 +++ .../toolannotations/toolannotations_test.go | 213 ++++++ 22 files changed, 3873 insertions(+) create mode 100644 internal/preflight/bench_test.go create mode 100644 internal/preflight/classify.go create mode 100644 internal/preflight/classify_test.go create mode 100644 internal/preflight/contracts_drift_test.go create mode 100644 internal/preflight/doc.go create mode 100644 internal/preflight/evaluator.go create mode 100644 internal/preflight/evaluator_test.go create mode 100644 internal/preflight/fakes_test.go create mode 100644 internal/preflight/reasons.go create mode 100644 internal/preflight/reasons_test.go create mode 100644 internal/preflight/request.go create mode 100644 internal/preflight/scope.go create mode 100644 internal/preflight/scope_test.go create mode 100644 internal/preflight/suggest.go create mode 100644 internal/preflight/suggest_test.go create mode 100644 internal/preflight/tier_test.go create mode 100644 internal/toolannotations/toolannotations.go create mode 100644 internal/toolannotations/toolannotations_test.go diff --git a/cmd/generate-types/main.go b/cmd/generate-types/main.go index 47453619..203b59fe 100644 --- a/cmd/generate-types/main.go +++ b/cmd/generate-types/main.go @@ -114,6 +114,90 @@ export type RejectionReason = 'queue_full' | 'queue_timeout'; /** Limiter tier that shed the call (activity metadata rejection_scope). */ export type RejectionScope = 'server' | 'global'; +`) + + // Required-tools preflight (Spec 098) - generated from internal/contracts/types.go, + // which mirrors internal/preflight/reasons.go (the single source of truth for + // the taxonomy). A drift test in internal/preflight keeps the two identical. + sb.WriteString(`// Preflight (Spec 098) - generated from internal/contracts/types.go +export const PreflightStatusReady = 'ready' as const; +export const PreflightStatusUnavailable = 'unavailable' as const; +export type PreflightStatus = typeof PreflightStatusReady | typeof PreflightStatusUnavailable; + +/** + * Closed 15-code failure enum. Additive-only: treat an unknown code as + * non-retryable. 'server_saturated' is reserved and not emitted. + */ +export type PreflightReason = + | 'server_initializing' + | 'server_unhealthy' + | 'server_disabled' + | 'server_quarantined' + | 'tool_pending_approval' + | 'tool_changed' + | 'tool_blocked_by_user' + | 'oauth_required' + | 'hash_mismatch' + | 'server_not_in_scope' + | 'tool_denied_by_config' + | 'missing_annotation' + | 'policy_filtered' + | 'not_found' + | 'server_not_configured'; + +/** Set-level aggregate (worst class present); drives the CLI exit code 0/10/11/12. */ +export const PreflightVerdictReady = 'ready' as const; +export const PreflightVerdictDegradedRetryable = 'degraded_retryable' as const; +export const PreflightVerdictBlocked = 'blocked' as const; +export const PreflightVerdictUnknownIds = 'unknown_ids' as const; +export type PreflightVerdict = + | typeof PreflightVerdictReady + | typeof PreflightVerdictDegradedRetryable + | typeof PreflightVerdictBlocked + | typeof PreflightVerdictUnknownIds; + +export interface PreflightToolRef { + id: string; + /** "sha256/v{N}:{hex}" - the schema version distinguishes a proxy hash bump from upstream drift. */ + pin_hash?: string; +} + +export interface PreflightPolicy { + read_only_only?: boolean; + exclude_destructive?: boolean; + exclude_open_world?: boolean; +} + +export interface PreflightRequest { + tools: PreflightToolRef[]; + profile?: string; + policy?: PreflightPolicy; + wait_ms?: number; +} + +export interface PreflightToolResult { + id: string; + status: PreflightStatus; + /** Present only when status is 'unavailable'. */ + reason?: PreflightReason; + retryable?: boolean; + /** Health-action vocabulary; omitted (not 'none') when the reason has no action. */ + action?: HealthAction; + detail?: string; + remediation?: string; + /** Operator tier + ready results only; never disclosed to an agent token. */ + hash?: string; + /** Up to 3 nearest caller-visible ids, on not_found only. */ + did_you_mean?: string[]; +} + +export interface PreflightResponse { + verdict: PreflightVerdict; + checked_at: string; // RFC3339 + waited_ms?: number; + tools: PreflightToolResult[]; +} + `) // Server types @@ -241,6 +325,10 @@ export interface IsolationDefaults { held_reason?: string; held_verdict?: string; held_signals?: string[]; + // The tool's current hash in the preflight pin format "sha256/v{N}:{hex}" + // (spec 098 FR-011) — the value to paste into a preflight pin. Operator tier + // only: absent for agent-token callers and for tools with no stored hash. + hash?: string; } export interface SearchResult { diff --git a/internal/auth/agent_token.go b/internal/auth/agent_token.go index b512c4f5..75d15c29 100644 --- a/internal/auth/agent_token.go +++ b/internal/auth/agent_token.go @@ -46,6 +46,25 @@ type AgentToken struct { ProfilePin string `json:"profile_pin,omitempty"` // Profile this token is pinned to (Profiles v2 T3) } +// AuthContext builds the request AuthContext for a validated agent token. It +// is the single constructor for the agent tier so no auth path can silently +// drop a field: the REST path used to omit ProfilePin, which meant a +// profile-pinned token evaluated (and, with Spec 098, preflighted) against the +// unpinned server set. Returns nil for a nil token. +func (t *AgentToken) AuthContext() *AuthContext { + if t == nil { + return nil + } + return &AuthContext{ + Type: AuthTypeAgent, + AgentName: t.Name, + TokenPrefix: t.TokenPrefix, + AllowedServers: t.AllowedServers, + Permissions: t.Permissions, + ProfilePin: t.ProfilePin, + } +} + // IsExpired returns true if the token has passed its expiry time. func (t *AgentToken) IsExpired() bool { if t.ExpiresAt.IsZero() { diff --git a/internal/auth/agent_token_test.go b/internal/auth/agent_token_test.go index 52f80504..94a17bfa 100644 --- a/internal/auth/agent_token_test.go +++ b/internal/auth/agent_token_test.go @@ -291,3 +291,43 @@ func TestGetOrCreateHMACKey_CreatesDir(t *testing.T) { require.NoError(t, err) assert.Len(t, key, 32) } + +// Spec 098 T010: AuthContext() is the single agent-tier context constructor — +// every field, including ProfilePin, must be carried through. +func TestAgentToken_AuthContext(t *testing.T) { + tok := &AgentToken{ + Name: "agent-1", + TokenPrefix: "mcp_agt_abcd", + AllowedServers: []string{"github"}, + Permissions: []string{PermRead}, + ProfilePin: "work", + } + + ac := tok.AuthContext() + if ac == nil { + t.Fatal("AuthContext() returned nil for a valid token") + } + if ac.Type != AuthTypeAgent { + t.Errorf("Type = %q, want %q", ac.Type, AuthTypeAgent) + } + if ac.AgentName != "agent-1" || ac.TokenPrefix != "mcp_agt_abcd" { + t.Errorf("identity fields not carried: %+v", ac) + } + if len(ac.AllowedServers) != 1 || ac.AllowedServers[0] != "github" { + t.Errorf("AllowedServers = %v", ac.AllowedServers) + } + if len(ac.Permissions) != 1 || ac.Permissions[0] != PermRead { + t.Errorf("Permissions = %v", ac.Permissions) + } + if ac.ProfilePin != "work" { + t.Errorf("ProfilePin = %q, want %q (the REST path used to drop it)", ac.ProfilePin, "work") + } + if ac.IsAdmin() { + t.Error("an agent token context must never be admin") + } + + var nilToken *AgentToken + if nilToken.AuthContext() != nil { + t.Error("AuthContext() on a nil token must return nil") + } +} diff --git a/internal/contracts/types.go b/internal/contracts/types.go index a0191f07..b0fe4b83 100644 --- a/internal/contracts/types.go +++ b/internal/contracts/types.go @@ -291,6 +291,17 @@ type Tool struct { HeldReason string `json:"held_reason,omitempty"` HeldVerdict string `json:"held_verdict,omitempty"` HeldSignals []string `json:"held_signals,omitempty"` + // Hash is the tool's current stored hash rendered in the preflight pin + // format "sha256/v{N}:{hex}" (Spec 098 FR-011), where N is the approval + // record's HashSchemaVersion. It is the authoring surface for + // `POST /api/v1/preflight` pins and `mcpproxy tools preflight --pin`: + // copy the value straight into a pin. + // + // Disclosure is OPERATOR TIER ONLY — same rule as the preflight per-tool + // result. The field is omitted for agent-token callers and for tools with + // no stored hash (no approval record yet, or a record written before + // hashes existed). + Hash string `json:"hash,omitempty"` } // DisabledToolStatus is the single machine-branchable reason a tool exists but @@ -1213,3 +1224,121 @@ type UpdatePolicy struct { // while machine-readable fields keep reporting the facts. NudgesSuppressed bool `json:"nudges_suppressed"` } + +// --------------------------------------------------------------------------- +// Required-tools preflight (Spec 098) +// +// The wire mirror of internal/preflight. The evaluator package owns the +// semantics (classes, retryability, precedence); these constants exist so the +// REST DTOs, the OpenAPI spec and the generated TypeScript all name the same +// values. An anti-drift unit test in internal/preflight asserts the two sets +// are identical, so a new code cannot land on one side only. +// --------------------------------------------------------------------------- + +// PreflightStatus is the per-tool outcome. `ready` is a success status, not a +// failure reason: ready results omit reason/retryable/action/detail/remediation. +type PreflightStatus = string + +const ( + PreflightStatusReady PreflightStatus = "ready" + PreflightStatusUnavailable PreflightStatus = "unavailable" +) + +// PreflightReason is the closed 15-code failure enum (Spec 098 FR-003). +// Evolution is additive-only; consumers MUST treat an unknown code as +// non-retryable. `server_saturated` is reserved and deliberately absent. +type PreflightReason = string + +const ( + PreflightReasonServerInitializing PreflightReason = "server_initializing" + PreflightReasonServerUnhealthy PreflightReason = "server_unhealthy" + PreflightReasonServerDisabled PreflightReason = "server_disabled" + PreflightReasonServerQuarantined PreflightReason = "server_quarantined" + PreflightReasonToolPendingApproval PreflightReason = "tool_pending_approval" + PreflightReasonToolChanged PreflightReason = "tool_changed" + PreflightReasonToolBlockedByUser PreflightReason = "tool_blocked_by_user" + PreflightReasonOAuthRequired PreflightReason = "oauth_required" + PreflightReasonHashMismatch PreflightReason = "hash_mismatch" + PreflightReasonServerNotInScope PreflightReason = "server_not_in_scope" + PreflightReasonToolDeniedByConfig PreflightReason = "tool_denied_by_config" + PreflightReasonMissingAnnotation PreflightReason = "missing_annotation" + PreflightReasonPolicyFiltered PreflightReason = "policy_filtered" + PreflightReasonNotFound PreflightReason = "not_found" + PreflightReasonServerNotConfigured PreflightReason = "server_not_configured" +) + +// PreflightVerdict is the set-level aggregate: the worst class present. It +// drives the CLI exit code (ready 0 < degraded_retryable 10 < blocked 11 < +// unknown_ids 12). +type PreflightVerdict = string + +const ( + PreflightVerdictReady PreflightVerdict = "ready" + PreflightVerdictDegradedRetryable PreflightVerdict = "degraded_retryable" + PreflightVerdictBlocked PreflightVerdict = "blocked" + PreflightVerdictUnknownIDs PreflightVerdict = "unknown_ids" +) + +// PreflightToolRef is one requested tool id, optionally hash-pinned. +type PreflightToolRef struct { + // ID is a canonical ":" id. A malformed id is answered with a + // per-ID not_found carrying a format hint, never a request-level error. + ID string `json:"id"` + // PinHash is "sha256/v{N}:{hex}" — the schema version is embedded so a + // proxy-side hash-algorithm bump is distinguishable from upstream drift. + PinHash string `json:"pin_hash,omitempty"` +} + +// PreflightPolicy carries the annotation filters the check evaluates under +// (spec 094 semantics: read_only_only -> exclude_destructive -> +// exclude_open_world, first excluding filter owns the omission). +type PreflightPolicy struct { + ReadOnlyOnly bool `json:"read_only_only,omitempty"` + ExcludeDestructive bool `json:"exclude_destructive,omitempty"` + ExcludeOpenWorld bool `json:"exclude_open_world,omitempty"` +} + +// PreflightRequest is the POST /api/v1/preflight body. +type PreflightRequest struct { + // Tools is 1..100 entries BEFORE dedup; duplicates are collapsed, and + // duplicate ids carrying different pins are a validation error. + Tools []PreflightToolRef `json:"tools"` + // Profile evaluates under a named profile's server scope. Unknown: 400. + Profile string `json:"profile,omitempty"` + Policy *PreflightPolicy `json:"policy,omitempty"` + // WaitMS polls local state for up to this many milliseconds (cap 10000) + // while every failure is retryable-class. + WaitMS int `json:"wait_ms,omitempty"` +} + +// PreflightToolResult is one per-tool verdict. Failure fields are present only +// for `unavailable`; `action` is omitted (not "none") when a reason has no +// action, matching the health-action vocabulary. +type PreflightToolResult struct { + ID string `json:"id"` + Status PreflightStatus `json:"status"` + Reason PreflightReason `json:"reason,omitempty"` + Retryable *bool `json:"retryable,omitempty"` + Action string `json:"action,omitempty"` + Detail string `json:"detail,omitempty"` + Remediation string `json:"remediation,omitempty"` + // Hash is the tool's current pin ("sha256/v{N}:{hex}") — operator tier, + // ready results only. Never disclosed to an agent token. + Hash string `json:"hash,omitempty"` + // DidYouMean carries up to 3 nearest caller-visible ids on not_found. It + // never crosses a scope boundary and never names a quarantined server's + // tools. + DidYouMean []string `json:"did_you_mean,omitempty"` +} + +// PreflightResponse is the 200 body: HTTP status reports whether the CHECK +// executed; the availability verdict lives here. +type PreflightResponse struct { + Verdict PreflightVerdict `json:"verdict"` + CheckedAt time.Time `json:"checked_at"` + // WaitedMS is present when wait_ms was requested (0 when the wait + // semaphore was exhausted and the request resolved immediately). + WaitedMS *int `json:"waited_ms,omitempty"` + // Tools are ordered by first occurrence of each unique id in the request. + Tools []PreflightToolResult `json:"tools"` +} diff --git a/internal/preflight/bench_test.go b/internal/preflight/bench_test.go new file mode 100644 index 00000000..e54ed9cc --- /dev/null +++ b/internal/preflight/bench_test.go @@ -0,0 +1,157 @@ +package preflight + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// Spec 098 SC-002 micro-benchmark: the evaluator alone, over a corpus with +// enough servers and tools that the scope/corpus walks are not free. +// +// The normative measurement is the handler-level benchmark in +// internal/httpapi (it adds response encoding and the activity-record build); +// this one exists so a regression can be attributed — evaluator or plumbing. +// +// The guard at the end is deliberately enormous (three orders of magnitude of +// headroom over the observed cost). It catches a change that makes an +// evaluation do I/O-scale work, and cannot flake on a loaded CI runner the way +// a tight wall-clock assertion would. +const benchPerOpCeiling = 50 * time.Millisecond + +// benchWorld builds a fixture of servers × tools with the readers the evaluator +// needs. Everything is in memory: the benchmark measures the evaluator, not a +// database. +func benchWorld(servers, toolsPerServer int) EvalContext { + index := &fakeIndex{ + tools: make(map[string][]IndexedTool, servers), + serverOrder: make([]string, 0, servers), + } + approvals := &fakeApprovals{records: make(map[string]*ApprovalState, servers*toolsPerServer)} + state := &fakeState{states: make(map[string]ServerRuntime, servers)} + policy := &fakePolicy{ + servers: make(map[string]ServerPolicy, servers), + denied: make(map[string]bool), + quarantine: true, + } + + for s := 0; s < servers; s++ { + serverName := fmt.Sprintf("srv%02d", s) + index.serverOrder = append(index.serverOrder, serverName) + state.states[serverName] = ServerRuntime{State: RuntimeStateReady} + policy.servers[serverName] = ServerPolicy{Found: true, Enabled: true} + + indexed := make([]IndexedTool, 0, toolsPerServer) + for i := 0; i < toolsPerServer; i++ { + toolName := fmt.Sprintf("tool%02d", i) + toolID := serverName + ":" + toolName + indexed = append(indexed, IndexedTool{ + Name: toolID, + Annotations: &config.ToolAnnotations{ + ReadOnlyHint: boolPtr(true), + DestructiveHint: boolPtr(false), + OpenWorldHint: boolPtr(false), + }, + }) + approvals.records[toolID] = &ApprovalState{ + Status: ApprovalStatusApproved, + CurrentHash: "abc123def456", + HashSchemaVersion: 2, + } + } + index.tools[serverName] = indexed + } + + return EvalContext{ + Index: index, + Approvals: approvals, + State: state, + Policy: policy, + Tier: TierOperator, + } +} + +func benchRefs(n int) []ToolRef { + refs := make([]ToolRef, 0, n) + for i := 0; i < n; i++ { + refs = append(refs, ToolRef{ID: fmt.Sprintf("srv%02d:tool%02d", i%10, i%5)}) + } + return refs +} + +func assertBenchCeiling(b *testing.B) { + b.Helper() + if b.N == 0 { + return + } + perOp := b.Elapsed() / time.Duration(b.N) + if perOp > benchPerOpCeiling { + b.Errorf("evaluation took %v per op, over the %v ceiling — the evaluator is doing far more than local reads", perOp, benchPerOpCeiling) + } +} + +// BenchmarkEvaluateReadySet is the SC-002 shape: 10 required tools, all ready. +func BenchmarkEvaluateReadySet(b *testing.B) { + ctx := context.Background() + ec := benchWorld(10, 5) + refs := benchRefs(10) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + results, err := Evaluate(ctx, ec, refs) + if err != nil { + b.Fatalf("evaluate: %v", err) + } + if len(results) != len(refs) { + b.Fatalf("expected %d results, got %d", len(refs), len(results)) + } + } + b.StopTimer() + assertBenchCeiling(b) +} + +// BenchmarkEvaluateMixedSet exercises the expensive branches: not_found builds +// the caller-visible corpus and runs did_you_mean over it, and the pinned entry +// runs hash comparison. +func BenchmarkEvaluateMixedSet(b *testing.B) { + ctx := context.Background() + ec := benchWorld(10, 5) + + refs := append(benchRefs(7), + ToolRef{ID: "srv00:tool99"}, // not_found + did_you_mean + ToolRef{ID: "ghost:tool00"}, // server_not_configured + ToolRef{ID: "srv01:tool01", PinHash: "sha256/v2:deadbeef"}, // hash_mismatch + ) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := Evaluate(ctx, ec, refs); err != nil { + b.Fatalf("evaluate: %v", err) + } + } + b.StopTimer() + assertBenchCeiling(b) +} + +// BenchmarkEvaluateMaxBatch is the request-size ceiling (100 ids), so the cost +// of the largest legal request is on record. +func BenchmarkEvaluateMaxBatch(b *testing.B) { + ctx := context.Background() + ec := benchWorld(10, 5) + refs := benchRefs(100) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := Evaluate(ctx, ec, refs); err != nil { + b.Fatalf("evaluate: %v", err) + } + } + b.StopTimer() + assertBenchCeiling(b) +} diff --git a/internal/preflight/classify.go b/internal/preflight/classify.go new file mode 100644 index 00000000..31fc51b8 --- /dev/null +++ b/internal/preflight/classify.go @@ -0,0 +1,153 @@ +package preflight + +// Tool-approval status values, mirrored from internal/storage +// (ToolApprovalStatus*). They are duplicated rather than imported so this +// package stays a leaf with no storage dependency; a unit test asserts the +// mirror is exact, so the duplication cannot drift. +const ( + ApprovalStatusApproved = "approved" + ApprovalStatusPending = "pending" + ApprovalStatusChanged = "changed" +) + +// ServerPolicy is the config-derived (non-runtime) view of one upstream server. +// Found=false means no server with that name is configured at all. +type ServerPolicy struct { + Found bool + Enabled bool + Quarantined bool + // AutoApproveToolChanges mirrors ServerConfig.IsAutoApproveToolChanges() + // (equivalently IsQuarantineSkipped() / trust_mode auto): the server opted + // out of tool-level quarantine, so pending/changed approval records do NOT + // gate its tools. This is the divergence research D2 records: dispatch + // honors it, the old classifyServerToolStatus did not. + AutoApproveToolChanges bool +} + +// ApprovalState is the narrow read of a spec 032 ToolApprovalRecord. A nil +// *ApprovalState means "no record", which is the implicit-approved default — +// NOT an error and NOT a pending state. +type ApprovalState struct { + Status string + Disabled bool + CurrentHash string + HashSchemaVersion uint64 +} + +// ClassifyInputs are the locally-read facts the shared classifier needs. Every +// field is a plain value: the classifier performs no I/O, so it is callable +// from dispatch (latency-sensitive) and from the evaluator alike. +type ClassifyInputs struct { + Server ServerPolicy + // QuarantineEnabled is the GLOBAL quarantine switch (config.IsQuarantineEnabled()). + QuarantineEnabled bool + // ConfigDenied is the operator's enabled_tools/disabled_tools verdict. + ConfigDenied bool + // Approval is the tool's approval record, or nil when none exists. + Approval *ApprovalState +} + +// ToolClass is the shared classification consumed by the preflight evaluator, +// classifyServerToolStatus and describeGateReason (research D2). Exactly one +// class per tool, by fixed first-match precedence. +type ToolClass string + +const ( + ToolClassReady ToolClass = "ready" + ToolClassServerNotConfigured ToolClass = "server_not_configured" + ToolClassServerQuarantined ToolClass = "server_quarantined" + ToolClassServerDisabled ToolClass = "server_disabled" + ToolClassDeniedByConfig ToolClass = "denied_by_config" + ToolClassBlockedByUser ToolClass = "blocked_by_user" + ToolClassChanged ToolClass = "changed" + ToolClassPendingApproval ToolClass = "pending_approval" +) + +// ClassifyTool is the single tool-eligibility classification (Spec 098 FR-002, +// research D2). Dispatch behavior is ground truth, so three divergences of the +// legacy classifiers are resolved here in dispatch's favor: +// +// - the tool-level quarantine gate applies ONLY when the global quarantine +// switch is on AND the server has not opted out (trust_mode auto / +// auto_approve_tool_changes). classifyServerToolStatus used to check the +// approval status unconditionally, which reported false positives for +// auto-approving servers; +// - `changed` (rug-pull guard) is a class of its own, not collapsed into +// `pending_approval` — the two have different stories and different +// remediation text; +// - a user block (ToolApprovalRecord.Disabled) applies unconditionally, even +// for auto-approving servers: it is a user decision, not a quarantine gate. +// +// Precedence: server not configured → quarantined → disabled → denied by config +// → blocked by user → changed → pending → ready. Existence (index presence) is +// NOT part of this classification; the evaluator interleaves it at its FR-004 +// slot, between server_disabled and tool_denied_by_config. +func ClassifyTool(in ClassifyInputs) ToolClass { + if !in.Server.Found { + return ToolClassServerNotConfigured + } + if in.Server.Quarantined { + return ToolClassServerQuarantined + } + if !in.Server.Enabled { + return ToolClassServerDisabled + } + if in.ConfigDenied { + return ToolClassDeniedByConfig + } + if in.Approval == nil { + return ToolClassReady + } + if in.Approval.Disabled { + return ToolClassBlockedByUser + } + if !quarantineGateApplies(in) { + return ToolClassReady + } + switch in.Approval.Status { + case ApprovalStatusChanged: + return ToolClassChanged + case ApprovalStatusPending: + return ToolClassPendingApproval + default: + return ToolClassReady + } +} + +// quarantineGateApplies reports whether pending/changed approval records gate +// this server's tools — the exact condition the dispatch path and +// describeGateReason use. +func quarantineGateApplies(in ClassifyInputs) bool { + return in.QuarantineEnabled && !in.Server.AutoApproveToolChanges +} + +// Reason maps a class to its preflight reason code. ToolClassReady maps to the +// empty string (no failure). +func (c ToolClass) Reason() Reason { + switch c { + case ToolClassServerNotConfigured: + return ReasonServerNotConfigured + case ToolClassServerQuarantined: + return ReasonServerQuarantined + case ToolClassServerDisabled: + return ReasonServerDisabled + case ToolClassDeniedByConfig: + return ReasonToolDeniedByConfig + case ToolClassBlockedByUser: + return ReasonToolBlockedByUser + case ToolClassChanged: + return ReasonToolChanged + case ToolClassPendingApproval: + return ReasonToolPendingApproval + case ToolClassReady: + return "" + default: + return "" + } +} + +// Callable reports whether the class permits dispatch. It is the one-line form +// the dispatch paths consume (FR-002). +func (c ToolClass) Callable() bool { + return c == ToolClassReady +} diff --git a/internal/preflight/classify_test.go b/internal/preflight/classify_test.go new file mode 100644 index 00000000..a5e7c1d5 --- /dev/null +++ b/internal/preflight/classify_test.go @@ -0,0 +1,182 @@ +package preflight + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" +) + +// The approval-status values are mirrored, not imported, to keep this package a +// leaf. The mirror must be exact. +func TestApprovalStatusMirrorsStorage(t *testing.T) { + assert.Equal(t, storage.ToolApprovalStatusApproved, ApprovalStatusApproved) + assert.Equal(t, storage.ToolApprovalStatusPending, ApprovalStatusPending) + assert.Equal(t, storage.ToolApprovalStatusChanged, ApprovalStatusChanged) +} + +func enabledServer() ServerPolicy { return ServerPolicy{Found: true, Enabled: true} } + +func TestClassifyTool_Precedence(t *testing.T) { + tests := []struct { + name string + in ClassifyInputs + want ToolClass + }{ + { + name: "no server record", + in: ClassifyInputs{Server: ServerPolicy{}}, + want: ToolClassServerNotConfigured, + }, + { + name: "quarantined beats disabled", + in: ClassifyInputs{Server: ServerPolicy{Found: true, Quarantined: true}}, + want: ToolClassServerQuarantined, + }, + { + name: "disabled", + in: ClassifyInputs{Server: ServerPolicy{Found: true}}, + want: ToolClassServerDisabled, + }, + { + name: "config denial beats a user block", + in: ClassifyInputs{ + Server: enabledServer(), ConfigDenied: true, QuarantineEnabled: true, + Approval: &ApprovalState{Disabled: true}, + }, + want: ToolClassDeniedByConfig, + }, + { + name: "user block beats a changed status", + in: ClassifyInputs{ + Server: enabledServer(), QuarantineEnabled: true, + Approval: &ApprovalState{Disabled: true, Status: ApprovalStatusChanged}, + }, + want: ToolClassBlockedByUser, + }, + { + name: "changed", + in: ClassifyInputs{ + Server: enabledServer(), QuarantineEnabled: true, + Approval: &ApprovalState{Status: ApprovalStatusChanged}, + }, + want: ToolClassChanged, + }, + { + name: "pending", + in: ClassifyInputs{ + Server: enabledServer(), QuarantineEnabled: true, + Approval: &ApprovalState{Status: ApprovalStatusPending}, + }, + want: ToolClassPendingApproval, + }, + { + name: "approved", + in: ClassifyInputs{ + Server: enabledServer(), QuarantineEnabled: true, + Approval: &ApprovalState{Status: ApprovalStatusApproved}, + }, + want: ToolClassReady, + }, + { + name: "no approval record is the implicit-approved default", + in: ClassifyInputs{Server: enabledServer(), QuarantineEnabled: true}, + want: ToolClassReady, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ClassifyTool(tt.in)) + }) + } +} + +// --- the three documented divergences (research D2) ------------------------- +// +// Dispatch behavior is ground truth; these are the cases where the legacy +// classifyServerToolStatus disagreed with it. + +func TestClassifyTool_QuarantineFlagsAreHonored(t *testing.T) { + pending := &ApprovalState{Status: ApprovalStatusPending} + + // Global quarantine OFF: a pending record does not gate the tool. + assert.Equal(t, ToolClassReady, ClassifyTool(ClassifyInputs{ + Server: enabledServer(), QuarantineEnabled: false, Approval: pending, + }), "classifyServerToolStatus used to report pending_approval regardless of the global switch") + + // Per-server opt-out (trust_mode auto / auto_approve_tool_changes). + assert.Equal(t, ToolClassReady, ClassifyTool(ClassifyInputs{ + Server: ServerPolicy{Found: true, Enabled: true, AutoApproveToolChanges: true}, + QuarantineEnabled: true, + Approval: pending, + })) + + // Both gates on: the record gates. + assert.Equal(t, ToolClassPendingApproval, ClassifyTool(ClassifyInputs{ + Server: enabledServer(), QuarantineEnabled: true, Approval: pending, + })) +} + +func TestClassifyTool_AutoApproveMakesChangedToolsReady(t *testing.T) { + changed := &ApprovalState{Status: ApprovalStatusChanged} + assert.Equal(t, ToolClassReady, ClassifyTool(ClassifyInputs{ + Server: ServerPolicy{Found: true, Enabled: true, AutoApproveToolChanges: true}, + QuarantineEnabled: true, + Approval: changed, + })) +} + +func TestClassifyTool_ChangedIsNotCollapsedIntoPending(t *testing.T) { + changed := ClassifyTool(ClassifyInputs{ + Server: enabledServer(), QuarantineEnabled: true, + Approval: &ApprovalState{Status: ApprovalStatusChanged}, + }) + pending := ClassifyTool(ClassifyInputs{ + Server: enabledServer(), QuarantineEnabled: true, + Approval: &ApprovalState{Status: ApprovalStatusPending}, + }) + assert.NotEqual(t, pending, changed) + assert.Equal(t, ReasonToolChanged, changed.Reason()) + assert.Equal(t, ReasonToolPendingApproval, pending.Reason()) +} + +// A user block is a user decision, not a quarantine gate: it applies even when +// the server opted out of tool-level quarantine. +func TestClassifyTool_UserBlockIgnoresQuarantineOptOut(t *testing.T) { + assert.Equal(t, ToolClassBlockedByUser, ClassifyTool(ClassifyInputs{ + Server: ServerPolicy{Found: true, Enabled: true, AutoApproveToolChanges: true}, + QuarantineEnabled: false, + Approval: &ApprovalState{Disabled: true}, + })) +} + +// An unrecognized status (a record written by a newer proxy) must not silently +// lock the tool — the quarantine states are the closed set that gates. +func TestClassifyTool_UnknownStatusIsNotAGate(t *testing.T) { + assert.Equal(t, ToolClassReady, ClassifyTool(ClassifyInputs{ + Server: enabledServer(), QuarantineEnabled: true, + Approval: &ApprovalState{Status: "some_future_status"}, + })) +} + +func TestToolClass_ReasonAndCallable(t *testing.T) { + cases := map[ToolClass]Reason{ + ToolClassServerNotConfigured: ReasonServerNotConfigured, + ToolClassServerQuarantined: ReasonServerQuarantined, + ToolClassServerDisabled: ReasonServerDisabled, + ToolClassDeniedByConfig: ReasonToolDeniedByConfig, + ToolClassBlockedByUser: ReasonToolBlockedByUser, + ToolClassChanged: ReasonToolChanged, + ToolClassPendingApproval: ReasonToolPendingApproval, + ToolClassReady: "", + } + for class, reason := range cases { + assert.Equal(t, reason, class.Reason(), "class %s", class) + assert.Equal(t, class == ToolClassReady, class.Callable(), "class %s", class) + if reason != "" { + assert.True(t, ValidReason(reason), "every class reason is a member of the enum") + } + } +} diff --git a/internal/preflight/contracts_drift_test.go b/internal/preflight/contracts_drift_test.go new file mode 100644 index 00000000..0ac7828c --- /dev/null +++ b/internal/preflight/contracts_drift_test.go @@ -0,0 +1,76 @@ +package preflight_test + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" +) + +// The taxonomy lives once, in internal/preflight. internal/contracts mirrors it +// for the wire (and, through cmd/generate-types, for the frontend). This test is +// the fence: a code added on one side without the other fails here rather than +// in production, where a UI would render an unknown badge or the OpenAPI enum +// would omit a real verdict. +func TestContractsMirrorsTheReasonEnum(t *testing.T) { + mirrored := []contracts.PreflightReason{ + contracts.PreflightReasonServerInitializing, + contracts.PreflightReasonServerUnhealthy, + contracts.PreflightReasonServerDisabled, + contracts.PreflightReasonServerQuarantined, + contracts.PreflightReasonToolPendingApproval, + contracts.PreflightReasonToolChanged, + contracts.PreflightReasonToolBlockedByUser, + contracts.PreflightReasonOAuthRequired, + contracts.PreflightReasonHashMismatch, + contracts.PreflightReasonServerNotInScope, + contracts.PreflightReasonToolDeniedByConfig, + contracts.PreflightReasonMissingAnnotation, + contracts.PreflightReasonPolicyFiltered, + contracts.PreflightReasonNotFound, + contracts.PreflightReasonServerNotConfigured, + } + + wire := make(map[string]bool, len(mirrored)) + for _, code := range mirrored { + assert.True(t, preflight.ValidReason(code), "contracts exposes %q, which the evaluator does not know", code) + wire[code] = true + } + + evaluator := make(map[string]bool) + for _, code := range preflight.AllReasons() { + evaluator[code] = true + } + assert.Equal(t, evaluator, wire, "internal/preflight and internal/contracts must expose the same closed enum") +} + +func TestContractsMirrorsStatusesAndVerdicts(t *testing.T) { + assert.Equal(t, preflight.StatusReady, contracts.PreflightStatusReady) + assert.Equal(t, preflight.StatusUnavailable, contracts.PreflightStatusUnavailable) + + assert.Equal(t, preflight.VerdictReady, contracts.PreflightVerdictReady) + assert.Equal(t, preflight.VerdictDegradedRetryable, contracts.PreflightVerdictDegradedRetryable) + assert.Equal(t, preflight.VerdictBlocked, contracts.PreflightVerdictBlocked) + assert.Equal(t, preflight.VerdictUnknownIDs, contracts.PreflightVerdictUnknownIDs) +} + +// The generated TypeScript is the third copy. Reading the committed file keeps +// the frontend union honest without a Node toolchain in the Go test suite. +func TestGeneratedTypeScriptCarriesEveryReason(t *testing.T) { + data, err := os.ReadFile("../../frontend/src/types/contracts.ts") + require.NoError(t, err, "run: go run ./cmd/generate-types") + ts := string(data) + + for _, code := range preflight.AllReasons() { + assert.True(t, strings.Contains(ts, "'"+code+"'"), + "frontend/src/types/contracts.ts is missing reason %q — add it to cmd/generate-types and regenerate", code) + } + for _, name := range []string{"PreflightRequest", "PreflightResponse", "PreflightToolResult", "PreflightToolRef", "PreflightPolicy"} { + assert.True(t, strings.Contains(ts, name), "generated types are missing %s", name) + } +} diff --git a/internal/preflight/doc.go b/internal/preflight/doc.go new file mode 100644 index 00000000..83d68392 --- /dev/null +++ b/internal/preflight/doc.go @@ -0,0 +1,30 @@ +// Package preflight is the shared eligibility evaluator behind the +// required-tools preflight (Spec 098): given a caller-supplied tool ID and a +// caller context, it answers `ready` or exactly one failure reason from a +// closed 15-code enum, by a fixed precedence chain. +// +// Two invariants define this package and must survive every future change: +// +// 1. ZERO UPSTREAM I/O, ZERO RUNTIME MUTATION (FR-006). The evaluator reads +// only the narrow interfaces in evaluator.go — an index reader, an approval +// reader, a connection-state reader and a config-policy reader. It cannot +// reach a transport, an upstream client, a reconnect path or an index +// writer, because none of those types are reachable from here: the package +// imports no transport, no runtime and no upstream package. In particular a +// preflight must NEVER call index ForProfile (it lazily creates and caches +// per-profile indexes, i.e. mutation) — profile semantics are "shared index +// existence + profile scope filter", nothing more. +// +// 2. AN INFRASTRUCTURE ERROR IS AN ERROR, NEVER A REASON CODE. When the index, +// the approval store or the config-policy read fails, Evaluate returns an +// error and the caller answers 503. A reason code is a statement about the +// proxy's state; fabricating one from a failed read would make the whole +// taxonomy untrustworthy — an operator would chase a `not_found` that was +// really a BBolt hiccup. The only "absence" that legitimately becomes a +// reason is an absence the reader reported successfully. +// +// The reason enum, its classes, retryability, default actions, the precedence +// chain and the set-verdict/exit-code mapping all live in reasons.go as the +// single source of truth; internal/contracts mirrors them for the wire and a +// drift test keeps the two identical. +package preflight diff --git a/internal/preflight/evaluator.go b/internal/preflight/evaluator.go new file mode 100644 index 00000000..784e4503 --- /dev/null +++ b/internal/preflight/evaluator.go @@ -0,0 +1,617 @@ +package preflight + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/health" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/toolannotations" +) + +// --------------------------------------------------------------------------- +// Narrow read interfaces +// +// Every input the evaluator needs arrives through one of these four +// interfaces. They are deliberately minimal and read-only: there is no method +// here that can connect, reconnect, index, approve or write anything, which is +// what makes FR-006 (zero upstream I/O, zero runtime mutation) structural +// rather than a promise. Implementations live in the glue layer. +// --------------------------------------------------------------------------- + +// IndexedTool is the slice of indexed metadata the evaluator uses: identity and +// annotations. Name may be either "server:tool" or the bare tool name — the +// evaluator normalizes both, exactly like the search paths do. +type IndexedTool struct { + Name string + Annotations *config.ToolAnnotations +} + +// IndexReader reads the SHARED search index. Note what is absent: no +// per-profile index accessor. index.Manager.ForProfile lazily CREATES and +// caches a profile index — a mutation — so a preflight must never call it. +// Profile semantics here are "shared-index existence + profile scope filter". +type IndexReader interface { + // ToolsByServer returns the indexed tools for one server. A server with no + // indexed tools returns an empty slice and a nil error. + ToolsByServer(serverName string) ([]IndexedTool, error) + // IndexedServerNames returns every server present in the shared index. + IndexedServerNames() ([]string, error) +} + +// ApprovalReader reads spec 032 tool-approval records. +type ApprovalReader interface { + // ToolApproval returns the record for a tool, or (nil, nil) when no record + // exists (the implicit-approved default). It must return an error ONLY for + // genuine infrastructure failures — "no record" is not an error. + ToolApproval(serverName, toolName string) (*ApprovalState, error) +} + +// ServerRuntimeState is the evaluator's normalized connection state. It mirrors +// upstream/types.ConnectionState without importing it, so this package stays +// free of anything that can dial a socket. +type ServerRuntimeState string + +const ( + // RuntimeStateUnknown means the snapshot has no usable state for the + // server. The evaluator then makes NO connection-state claim. + RuntimeStateUnknown ServerRuntimeState = "" + RuntimeStateReady ServerRuntimeState = "ready" + // RuntimeStateConnecting / Discovering / Authenticating are the + // server-level "initializing" states (research D4). No per-tool indexing + // progress is ever claimed. + RuntimeStateConnecting ServerRuntimeState = "connecting" + RuntimeStateDiscovering ServerRuntimeState = "discovering" + RuntimeStateAuthenticating ServerRuntimeState = "authenticating" + // RuntimeStatePendingAuth is the deferred-OAuth state (FR-007): mapped to + // oauth_required explicitly, BEFORE any health fallthrough, because waiting + // cannot help without a login. + RuntimeStatePendingAuth ServerRuntimeState = "pending_auth" + RuntimeStateDisconnected ServerRuntimeState = "disconnected" + RuntimeStateError ServerRuntimeState = "error" +) + +// ServerRuntime is the read-only connection view of one server. +type ServerRuntime struct { + State ServerRuntimeState + // Detail is an occurrence-specific human note (e.g. the last error, or a + // spec 044 diagnostic message). Optional. + Detail string + // Action optionally overrides the default action for server_unhealthy with + // a best-effort suggestion from the spec 044 diagnostic classifier + // (restart / login / view_logs). Empty means "use the default". + Action string +} + +// StateReader reads the connection-state snapshot (stateview). It is a snapshot +// read: lock-free, never blocking, never triggering a connect. +type StateReader interface { + // ServerRuntime returns the runtime view of a server; found=false when the + // snapshot has no entry for it. + ServerRuntime(serverName string) (rt ServerRuntime, found bool) +} + +// ConfigPolicy reads configuration-derived policy. +type ConfigPolicy interface { + // ServerPolicy returns the config view of a server. A missing server is + // reported as ServerPolicy{Found: false}, NOT as an error. + ServerPolicy(serverName string) (ServerPolicy, error) + // ToolConfigDenied reports the enabled_tools / disabled_tools verdict. + ToolConfigDenied(serverName, toolName string) (bool, error) + // QuarantineEnabled is the global quarantine switch. + QuarantineEnabled() bool +} + +// --------------------------------------------------------------------------- +// Request / result types +// --------------------------------------------------------------------------- + +// ToolRef is one requested id, optionally hash-pinned. +type ToolRef struct { + ID string + PinHash string +} + +// EvalContext carries everything one evaluation needs. It is a value: build it +// per request, never share it across requests. +type EvalContext struct { + Index IndexReader + Approvals ApprovalReader + State StateReader + Policy ConfigPolicy + + // Tier selects the disclosure rules (FR-013). + Tier Tier + // Scope is the effective evaluation scope (token scope ∩ token pin ∩ + // requested profile — see ResolveScope). nil means unrestricted. + Scope *Scope + // Filters are the caller's annotation policy filters (spec 094 semantics). + Filters toolannotations.Filters + // Pins maps a requested id to its pin string, for callers that carry pins + // separately from the refs. A ToolRef.PinHash always wins over this map. + Pins map[string]string +} + +// Result is one per-tool verdict. It mirrors the wire DTO minus serialization +// concerns; failure fields are empty for a ready result. +type Result struct { + ID string + Status Status + Reason Reason + Retryable bool + Action string + Detail string + Remediation string + // Hash is the tool's current pin string ("sha256/v{N}:{hex}") — operator + // tier, ready results only. Never populated for the agent-token tier. + Hash string + // DidYouMean is populated only on not_found, from the caller-visible corpus. + DidYouMean []string +} + +// Canonical wording. The not_found texts are constants because FR-013 requires +// an out-of-scope result at the agent-token tier to be byte-indistinguishable +// from an ordinary not_found — same reason, retryable, action, detail and +// remediation. They are produced by one constructor (notFoundResult) for +// exactly that reason. +const ( + detailNotFound = "No tool with this id is available." + detailMalformedID = "Malformed tool id: expected the format :." +) + +// Evaluate answers one verdict per requested ref, in request order. +// +// It returns an error — never a fabricated reason code — when an underlying +// read fails (index, approvals, config policy) or the context is cancelled. The +// served surface answers 503 in that case: a reason code is a statement about +// proxy state, and inventing one from a failed read would poison the taxonomy. +func Evaluate(ctx context.Context, ec EvalContext, refs []ToolRef) ([]Result, error) { + results := make([]Result, 0, len(refs)) + corpus := &visibleCorpus{ec: &ec} + + for _, ref := range refs { + if err := ctx.Err(); err != nil { + return nil, err + } + res, err := evaluateOne(&ec, ref, corpus) + if err != nil { + return nil, err + } + results = append(results, res) + } + return results, nil +} + +// evaluateOne walks the FR-004 precedence chain for a single id. The order of +// the blocks below IS the normative chain — do not reorder without changing the +// spec. +func evaluateOne(ec *EvalContext, ref ToolRef, corpus *visibleCorpus) (Result, error) { + id := strings.TrimSpace(ref.ID) + + serverName, toolName, ok := splitToolID(id) + if !ok { + // A malformed id is a per-ID verdict, never a request-level error: one + // bad entry must not mask the verdicts of the rest (spec Edge Cases). + return unavailable(id, ReasonNotFound, detailMalformedID), nil + } + + policy, err := ec.Policy.ServerPolicy(serverName) + if err != nil { + return Result{}, fmt.Errorf("preflight: read server policy for %q: %w", serverName, err) + } + + // 1. server_not_configured — operator tier only. At the agent-token tier + // this is scope-silenced into the SAME not_found an out-of-scope server + // produces: if the two answers differed, a token could probe arbitrary + // names and learn which servers exist behind its scope (the exact leak + // FR-013 forbids). + if !policy.Found { + if ec.Tier == TierAgentToken { + return corpus.notFoundResult(id) + } + return unavailable(id, ReasonServerNotConfigured, + fmt.Sprintf("No upstream server named %q is configured.", serverName)), nil + } + + // 2. server_not_in_scope (operator tier) — the agent-token tier gets + // scope-silence: the SAME construction an absent tool produces. + if !ec.Scope.Allows(serverName) { + if ec.Tier == TierAgentToken { + return corpus.notFoundResult(id) + } + detail := fmt.Sprintf("Server %q is outside the evaluated scope; a session under this scope sees this id as not_found.", serverName) + if name := ec.Scope.Name(); name != "" { + detail = fmt.Sprintf("Server %q is outside profile %q; a session pinned to that profile sees this id as not_found.", serverName, name) + } + return unavailable(id, ReasonServerNotInScope, detail), nil + } + + // 3. server_quarantined — a quarantined server's tools are never indexed, + // so existence below it is unknowable, which is exactly why it outranks + // not_found. + if policy.Quarantined { + return unavailable(id, ReasonServerQuarantined, + fmt.Sprintf("Server %q is quarantined; its tools are withheld pending review.", serverName)), nil + } + + // 4. server_disabled + if !policy.Enabled { + return unavailable(id, ReasonServerDisabled, + fmt.Sprintf("Server %q is disabled.", serverName)), nil + } + + // 5. not_found — exact-id existence. The shared index is the primary + // source, but it is NOT authoritative on its own: the runtime + // de-indexes a tool the moment it becomes blocked, pending, or changed + // (spec 032), so a spec-032 approval record is equally valid evidence + // the tool exists upstream. When a record exists the chain falls + // through to the tool-level gates instead of reporting a misleading + // not_found (with an actively harmful did_you_mean). And when the + // server is not Ready, existence is unknowable — the connection-state + // verdict is returned instead of not_found (FR-005: never claim + // per-tool knowledge the runtime does not have). + indexed, err := lookupIndexed(ec, serverName, toolName) + if err != nil { + return Result{}, err + } + approval, err := ec.Approvals.ToolApproval(serverName, toolName) + if err != nil { + return Result{}, fmt.Errorf("preflight: read tool approval for %q: %w", id, err) + } + if indexed == nil && approval == nil { + if res, notReady := connectionVerdict(ec, id, serverName); notReady { + return res, nil + } + return corpus.notFoundResult(id) + } + + configDenied, err := ec.Policy.ToolConfigDenied(serverName, toolName) + if err != nil { + return Result{}, fmt.Errorf("preflight: read tool config policy for %q: %w", id, err) + } + + // 6-9. tool_denied_by_config → tool_blocked_by_user → tool_changed → + // tool_pending_approval, all from the shared classifier so preflight + // and dispatch cannot disagree (FR-002). + class := ClassifyTool(ClassifyInputs{ + Server: policy, + QuarantineEnabled: ec.Policy.QuarantineEnabled(), + ConfigDenied: configDenied, + Approval: approval, + }) + if !class.Callable() { + return unavailable(id, class.Reason(), classDetail(class, serverName, toolName)), nil + } + + // 10. hash_mismatch — evaluated only now that the tool is known to exist + // (FR-004): earlier states win over a pin failure. + if pin := pinFor(ec, ref, id); pin != "" { + if res, mismatch := checkPin(id, pin, approval, ec.Tier); mismatch { + return res, nil + } + } + + // 11-13. Connection state: oauth_required → server_unhealthy → + // server_initializing. All server-level (FR-005). + if res, unhealthy := connectionVerdict(ec, id, serverName); unhealthy { + return res, nil + } + + // 14. Annotation filters (spec 094 order: read_only_only → + // exclude_destructive → exclude_open_world; the first filter that + // excludes owns the omission). + if ec.Filters.Any() { + // A known-but-de-indexed tool (approval record, no index entry) has no + // readable annotations; nil is exactly the spec-094 missing-annotation + // case, so the shared classifier handles it without a special path. + var annotations *config.ToolAnnotations + if indexed != nil { + annotations = indexed.Annotations + } + if filterKey, explicit, excluded := toolannotations.ExcludeReasonFor(annotations, ec.Filters); excluded { + reason := ReasonMissingAnnotation + detail := fmt.Sprintf("Filter %s omits this tool: the upstream definition does not declare the required annotation.", filterKey) + if explicit { + reason = ReasonPolicyFiltered + detail = fmt.Sprintf("Filter %s omits this tool: it is explicitly annotated as unsafe for this filter.", filterKey) + } + return unavailable(id, reason, detail), nil + } + } + + // 15. ready + res := Result{ID: id, Status: StatusReady} + // Hash disclosure is operator-tier only (FR-013) and only on ready results. + if ec.Tier != TierAgentToken && approval != nil && approval.CurrentHash != "" { + res.Hash = FormatPin(approval.HashSchemaVersion, approval.CurrentHash) + } + return res, nil +} + +// unavailable builds a failure result from the taxonomy defaults. +func unavailable(id string, reason Reason, detail string) Result { + return Result{ + ID: id, + Status: StatusUnavailable, + Reason: reason, + Retryable: Retryable(reason), + Action: DefaultAction(reason), + Detail: detail, + Remediation: DefaultRemediation(reason), + } +} + +// classDetail renders the occurrence-specific note for a classifier verdict. +func classDetail(class ToolClass, serverName, toolName string) string { + id := serverName + ":" + toolName + switch class { + case ToolClassDeniedByConfig: + return fmt.Sprintf("Tool %q is denied by the server's enabled_tools/disabled_tools policy.", id) + case ToolClassBlockedByUser: + return fmt.Sprintf("Tool %q was disabled in mcpproxy.", id) + case ToolClassChanged: + return fmt.Sprintf("Tool %q changed after approval (rug-pull guard); it is locked pending review.", id) + case ToolClassPendingApproval: + return fmt.Sprintf("Tool %q is pending security approval.", id) + case ToolClassServerNotConfigured, ToolClassServerQuarantined, ToolClassServerDisabled, ToolClassReady: + return "" + default: + return "" + } +} + +// connectionVerdict applies the three connection-state gates. found=false in the +// snapshot means the evaluator makes no claim at all: absence of runtime +// information is not evidence of ill health (and the served surface refuses with +// 503 when the runtime is unavailable entirely, FR-006). +func connectionVerdict(ec *EvalContext, id, serverName string) (Result, bool) { + if ec.State == nil { + return Result{}, false + } + rt, found := ec.State.ServerRuntime(serverName) + if !found { + return Result{}, false + } + + switch rt.State { + case RuntimeStatePendingAuth: + // FR-007: explicit map, ahead of any health fallthrough. + res := unavailable(id, ReasonOAuthRequired, + fmt.Sprintf("Server %q is waiting for OAuth login.", serverName)) + if rt.Detail != "" { + res.Detail = rt.Detail + } + return res, true + + case RuntimeStateError, RuntimeStateDisconnected: + detail := fmt.Sprintf("Server %q is not connected.", serverName) + if rt.Detail != "" { + detail = rt.Detail + } + res := unavailable(id, ReasonServerUnhealthy, detail) + if action := normalizeHealthAction(rt.Action); action != "" { + res.Action = action + } + return res, true + + case RuntimeStateConnecting, RuntimeStateDiscovering, RuntimeStateAuthenticating: + // Server-level only — never a per-tool claim about indexing progress. + detail := fmt.Sprintf("Server %q is still connecting or discovering its tools.", serverName) + if rt.Detail != "" { + detail = rt.Detail + } + return unavailable(id, ReasonServerInitializing, detail), true + + case RuntimeStateReady, RuntimeStateUnknown: + return Result{}, false + + default: + return Result{}, false + } +} + +// normalizeHealthAction accepts only the existing health-action vocabulary for +// the best-effort server_unhealthy override; anything else falls back to the +// taxonomy default. +func normalizeHealthAction(action string) string { + switch action { + case health.ActionRestart, health.ActionLogin, health.ActionViewLogs: + return action + default: + return "" + } +} + +// lookupIndexed resolves an exact (server, tool) pair against the shared index. +// Exact match only — no fuzzy resolution, no live ListTools fallback. +func lookupIndexed(ec *EvalContext, serverName, toolName string) (*IndexedTool, error) { + if ec.Index == nil { + return nil, fmt.Errorf("preflight: no index reader configured") + } + tools, err := ec.Index.ToolsByServer(serverName) + if err != nil { + return nil, fmt.Errorf("preflight: read index for server %q: %w", serverName, err) + } + full := serverName + ":" + toolName + for i := range tools { + if tools[i].Name == full || tools[i].Name == toolName { + t := tools[i] + return &t, nil + } + } + return nil, nil +} + +// splitToolID splits a canonical ":" id. Whitespace is never +// significant, so both segments are trimmed; ok=false when either is blank. +func splitToolID(id string) (serverName, toolName string, ok bool) { + parts := strings.SplitN(strings.TrimSpace(id), ":", 2) + if len(parts) != 2 { + return "", "", false + } + serverName, toolName = strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + if serverName == "" || toolName == "" { + return "", "", false + } + return serverName, toolName, true +} + +// pinFor resolves the pin for a ref: an explicit ToolRef.PinHash wins over the +// EvalContext.Pins map. +func pinFor(ec *EvalContext, ref ToolRef, id string) string { + if ref.PinHash != "" { + return ref.PinHash + } + if ec.Pins == nil { + return "" + } + return ec.Pins[id] +} + +// --------------------------------------------------------------------------- +// Hash pins (FR-011, research D4) +// --------------------------------------------------------------------------- + +const pinPrefix = "sha256/v" + +// FormatPin renders a stored hash as the wire pin format +// "sha256/v{HashSchemaVersion}:{hex}". +func FormatPin(schemaVersion uint64, hash string) string { + return fmt.Sprintf("%s%d:%s", pinPrefix, schemaVersion, hash) +} + +// ParsePin decodes a pin string. The schema version is part of the format so a +// proxy-side hash-algorithm bump is distinguishable from genuine upstream drift +// (it changes `detail`, not the reason code). +func ParsePin(pin string) (schemaVersion uint64, hash string, err error) { + rest, found := strings.CutPrefix(strings.TrimSpace(pin), pinPrefix) + if !found { + return 0, "", fmt.Errorf("invalid pin %q: expected %s:", pin, pinPrefix) + } + verStr, hexStr, ok := strings.Cut(rest, ":") + if !ok || verStr == "" || hexStr == "" { + return 0, "", fmt.Errorf("invalid pin %q: expected %s:", pin, pinPrefix) + } + v, convErr := strconv.ParseUint(verStr, 10, 64) + if convErr != nil { + return 0, "", fmt.Errorf("invalid pin %q: schema version is not a number", pin) + } + return v, hexStr, nil +} + +// checkPin compares a supplied pin against the tool's current stored hash. +// mismatch=false means the pin matches (or the caller supplied none). +// +// Fail-closed cases (all reported as hash_mismatch, distinguished in `detail`): +// an unparseable pin, a schema-version bump, no stored hash to verify against, +// and genuine drift. A pin that cannot be verified must never pass a gate whose +// entire purpose is to detect definition drift. +func checkPin(id, pin string, approval *ApprovalState, tier Tier) (Result, bool) { + pinVersion, pinHash, err := ParsePin(pin) + if err != nil { + return unavailable(id, ReasonHashMismatch, + fmt.Sprintf("Invalid pin format: expected %s:.", pinPrefix)), true + } + if approval == nil || approval.CurrentHash == "" { + return unavailable(id, ReasonHashMismatch, + "No stored hash is available for this tool, so the pin cannot be verified; re-pin from the tool's current definition."), true + } + if approval.HashSchemaVersion != pinVersion { + return unavailable(id, ReasonHashMismatch, + fmt.Sprintf("Hash schema changed (proxy upgrade): pin uses schema v%d, the proxy now stores v%d. Relock the pin.", + pinVersion, approval.HashSchemaVersion)), true + } + if approval.CurrentHash != pinHash { + // Hashes are operator-tier disclosure only (FR-013). + detail := "The pinned hash does not match the tool's current definition." + if tier != TierAgentToken { + detail = fmt.Sprintf("Pinned %s but the tool's current hash is %s.", + FormatPin(pinVersion, pinHash), FormatPin(approval.HashSchemaVersion, approval.CurrentHash)) + } + return unavailable(id, ReasonHashMismatch, detail), true + } + return Result{}, false +} + +// --------------------------------------------------------------------------- +// not_found + did_you_mean +// --------------------------------------------------------------------------- + +// visibleCorpus lazily builds the caller-visible id list used for did_you_mean +// suggestions: in-scope, configured, non-quarantined servers only. Built at +// most once per Evaluate call, and only when some id actually misses. +type visibleCorpus struct { + ec *EvalContext + ids []string + built bool + buildE error +} + +func (c *visibleCorpus) candidates() ([]string, error) { + if c.built { + return c.ids, c.buildE + } + c.built = true + + ec := c.ec + if ec.Index == nil { + c.buildE = fmt.Errorf("preflight: no index reader configured") + return nil, c.buildE + } + servers, err := ec.Index.IndexedServerNames() + if err != nil { + c.buildE = fmt.Errorf("preflight: list indexed servers: %w", err) + return nil, c.buildE + } + for _, server := range servers { + if !ec.Scope.Allows(server) { + continue + } + policy, perr := ec.Policy.ServerPolicy(server) + if perr != nil { + c.buildE = fmt.Errorf("preflight: read server policy for %q: %w", server, perr) + return nil, c.buildE + } + // Never suggest names from a quarantined, unconfigured or disabled + // server (FR-013): a suggestion must not confirm what the caller may + // not see. + if !policy.Found || policy.Quarantined || !policy.Enabled { + continue + } + tools, terr := ec.Index.ToolsByServer(server) + if terr != nil { + c.buildE = fmt.Errorf("preflight: read index for server %q: %w", server, terr) + return nil, c.buildE + } + for i := range tools { + name := tools[i].Name + if idx := strings.Index(name, ":"); idx >= 0 { + name = name[idx+1:] + } + if name == "" { + continue + } + c.ids = append(c.ids, server+":"+name) + } + } + return c.ids, nil +} + +// notFoundResult is the ONE constructor for a not_found verdict. Both an absent +// tool and an out-of-scope id at the agent-token tier go through it, which is +// what makes the two byte-indistinguishable (FR-013) by construction rather +// than by careful copy-editing. Suggestions are drawn from the caller-visible +// corpus only, so they can never cross the scope boundary. +func (c *visibleCorpus) notFoundResult(id string) (Result, error) { + res := unavailable(id, ReasonNotFound, detailNotFound) + candidates, err := c.candidates() + if err != nil { + return Result{}, err + } + if suggestions := Suggest(id, candidates); len(suggestions) > 0 { + res.DidYouMean = suggestions + } + return res, nil +} diff --git a/internal/preflight/evaluator_test.go b/internal/preflight/evaluator_test.go new file mode 100644 index 00000000..a45493b1 --- /dev/null +++ b/internal/preflight/evaluator_test.go @@ -0,0 +1,573 @@ +package preflight + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +func evalOne(t *testing.T, w *world, ref ToolRef) Result { + t.Helper() + results, err := Evaluate(context.Background(), w.ctx(), []ToolRef{ref}) + require.NoError(t, err) + require.Len(t, results, 1) + return results[0] +} + +func TestEvaluate_Ready(t *testing.T) { + res := evalOne(t, healthyWorld(), ToolRef{ID: id}) + + assert.Equal(t, id, res.ID) + assert.Equal(t, StatusReady, res.Status) + assert.Empty(t, res.Reason, "a ready result carries no failure reason") + assert.False(t, res.Retryable) + assert.Empty(t, res.Action) + assert.Empty(t, res.Detail) + assert.Empty(t, res.Remediation) + assert.Empty(t, res.DidYouMean) + assert.Equal(t, "sha256/v2:abc123", res.Hash, "operator tier gets the current pin on ready results") +} + +// --- every enum cell -------------------------------------------------------- + +func TestEvaluate_EveryReasonCell(t *testing.T) { + tests := []struct { + name string + build func() *world + ref ToolRef + reason Reason + }{ + { + name: "server_not_configured", + build: func() *world { return healthyWorld().unconfigure() }, + ref: ToolRef{ID: id}, + reason: ReasonServerNotConfigured, + }, + { + name: "server_not_in_scope (operator tier)", + build: func() *world { return healthyWorld().outOfScope() }, + ref: ToolRef{ID: id}, + reason: ReasonServerNotInScope, + }, + { + name: "server_quarantined", + build: func() *world { return healthyWorld().quarantine() }, + ref: ToolRef{ID: id}, + reason: ReasonServerQuarantined, + }, + { + name: "server_disabled", + build: func() *world { return healthyWorld().disable() }, + ref: ToolRef{ID: id}, + reason: ReasonServerDisabled, + }, + { + name: "not_found", + build: func() *world { return healthyWorld().unindex().forget() }, + ref: ToolRef{ID: id}, + reason: ReasonNotFound, + }, + { + name: "tool_denied_by_config", + build: func() *world { return healthyWorld().denyByConfig() }, + ref: ToolRef{ID: id}, + reason: ReasonToolDeniedByConfig, + }, + { + name: "tool_blocked_by_user", + build: func() *world { return healthyWorld().approval(func(a *ApprovalState) { a.Disabled = true }) }, + ref: ToolRef{ID: id}, + reason: ReasonToolBlockedByUser, + }, + { + name: "tool_changed", + build: func() *world { + return healthyWorld().approval(func(a *ApprovalState) { a.Status = ApprovalStatusChanged }) + }, + ref: ToolRef{ID: id}, + reason: ReasonToolChanged, + }, + { + name: "tool_pending_approval", + build: func() *world { + return healthyWorld().approval(func(a *ApprovalState) { a.Status = ApprovalStatusPending }) + }, + ref: ToolRef{ID: id}, + reason: ReasonToolPendingApproval, + }, + { + name: "hash_mismatch", + build: healthyWorld, + ref: ToolRef{ID: id, PinHash: "sha256/v2:deadbeef"}, + reason: ReasonHashMismatch, + }, + { + name: "oauth_required", + build: func() *world { return healthyWorld().runtime(RuntimeStatePendingAuth) }, + ref: ToolRef{ID: id}, + reason: ReasonOAuthRequired, + }, + { + name: "server_unhealthy (error)", + build: func() *world { return healthyWorld().runtime(RuntimeStateError) }, + ref: ToolRef{ID: id}, + reason: ReasonServerUnhealthy, + }, + { + name: "server_unhealthy (disconnected)", + build: func() *world { return healthyWorld().runtime(RuntimeStateDisconnected) }, + ref: ToolRef{ID: id}, + reason: ReasonServerUnhealthy, + }, + { + name: "server_initializing (connecting)", + build: func() *world { return healthyWorld().runtime(RuntimeStateConnecting) }, + ref: ToolRef{ID: id}, + reason: ReasonServerInitializing, + }, + { + name: "server_initializing (discovering)", + build: func() *world { return healthyWorld().runtime(RuntimeStateDiscovering) }, + ref: ToolRef{ID: id}, + reason: ReasonServerInitializing, + }, + { + name: "server_initializing (authenticating)", + build: func() *world { return healthyWorld().runtime(RuntimeStateAuthenticating) }, + ref: ToolRef{ID: id}, + reason: ReasonServerInitializing, + }, + { + name: "missing_annotation", + build: func() *world { + w := healthyWorld().annotations(nil) + w.filters.readOnlyOnly = true + return w + }, + ref: ToolRef{ID: id}, + reason: ReasonMissingAnnotation, + }, + { + name: "policy_filtered", + build: func() *world { + w := healthyWorld().annotations(&config.ToolAnnotations{ReadOnlyHint: boolPtr(false)}) + w.filters.readOnlyOnly = true + return w + }, + ref: ToolRef{ID: id}, + reason: ReasonPolicyFiltered, + }, + } + + seen := map[Reason]bool{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := evalOne(t, tt.build(), tt.ref) + assert.Equal(t, StatusUnavailable, res.Status) + assert.Equal(t, tt.reason, res.Reason) + // Every failure result restates the taxonomy row. + assert.Equal(t, Retryable(tt.reason), res.Retryable) + assert.Equal(t, DefaultAction(tt.reason), res.Action) + assert.Equal(t, DefaultRemediation(tt.reason), res.Remediation) + assert.NotEmpty(t, res.Detail, "a failure always says something occurrence-specific") + assert.Empty(t, res.Hash, "hashes are never disclosed on a failure result") + }) + seen[tt.reason] = true + } + + // FR-016 in miniature: no enum code may ship without a cell here. + for _, code := range AllReasons() { + assert.True(t, seen[code], "reason %s has no evaluator test cell", code) + } +} + +// --- adjacent-precedence co-occurrence (FR-004) ----------------------------- + +// Each case makes TWO adjacent precedence conditions true at once and asserts +// the higher-ranked one wins. Where two conditions cannot physically co-occur +// (a server has exactly one connection state; an approval record exactly one +// status), the test instead pins the discrimination that the pair encodes. +func TestEvaluate_PrecedenceCoOccurrence(t *testing.T) { + tests := []struct { + name string + build func() *world + ref ToolRef + want Reason + reason string + }{ + { + name: "not_configured beats not_in_scope", + build: func() *world { return healthyWorld().unconfigure().outOfScope() }, + ref: ToolRef{ID: id}, + want: ReasonServerNotConfigured, + reason: "a server that does not exist cannot be 'out of scope'", + }, + { + name: "not_in_scope beats quarantined", + build: func() *world { return healthyWorld().quarantine().outOfScope() }, + ref: ToolRef{ID: id}, + want: ReasonServerNotInScope, + reason: "scope is evaluated before any state of a server the caller cannot see", + }, + { + name: "quarantined beats disabled", + build: func() *world { return healthyWorld().quarantine().disable() }, + ref: ToolRef{ID: id}, + want: ReasonServerQuarantined, + reason: "quarantine is the security story and outranks the admin toggle", + }, + { + name: "quarantined beats not_found", + build: func() *world { return healthyWorld().quarantine().unindex() }, + ref: ToolRef{ID: id}, + want: ReasonServerQuarantined, + reason: "quarantined servers' tools are never indexed, so existence is unknowable", + }, + { + name: "disabled beats not_found", + build: func() *world { return healthyWorld().disable().unindex() }, + ref: ToolRef{ID: id}, + want: ReasonServerDisabled, + reason: "a disabled server's index may be stale; the actionable fact is the disable", + }, + { + name: "not_found beats denied_by_config", + build: func() *world { return healthyWorld().unindex().forget().denyByConfig() }, + ref: ToolRef{ID: id}, + want: ReasonNotFound, + reason: "a truly unknown tool (no index entry, no approval record) outranks a policy about it", + }, + { + name: "de-indexed blocked tool keeps tool_blocked_by_user", + build: func() *world { return healthyWorld().unindex().approval(func(a *ApprovalState) { a.Disabled = true }) }, + ref: ToolRef{ID: id}, + want: ReasonToolBlockedByUser, + reason: "the runtime de-indexes blocked tools; the approval record proves existence, so not_found must not shadow the real reason", + }, + { + name: "de-indexed changed tool keeps tool_changed", + build: func() *world { + return healthyWorld().unindex().approval(func(a *ApprovalState) { a.Status = ApprovalStatusChanged }) + }, + ref: ToolRef{ID: id}, + want: ReasonToolChanged, + reason: "a rug-pulled tool is de-indexed; reporting not_found (plus a cross-server did_you_mean) would be actively misleading", + }, + { + name: "de-indexed pending tool keeps tool_pending_approval", + build: func() *world { + return healthyWorld().unindex().approval(func(a *ApprovalState) { a.Status = ApprovalStatusPending }) + }, + ref: ToolRef{ID: id}, + want: ReasonToolPendingApproval, + reason: "a post-baseline new tool is held out of the index until approved; the record still proves it exists", + }, + { + name: "unknown id on an initializing server is server_initializing, not not_found", + build: func() *world { return healthyWorld().unindex().forget().runtime(RuntimeStateDiscovering) }, + ref: ToolRef{ID: id}, + want: ReasonServerInitializing, + reason: "existence is unknowable mid-discovery (FR-005); not_found requires an authoritative Ready view", + }, + { + name: "unknown id on an unhealthy server is server_unhealthy, not not_found", + build: func() *world { return healthyWorld().unindex().forget().runtime(RuntimeStateError) }, + ref: ToolRef{ID: id}, + want: ReasonServerUnhealthy, + reason: "a dead server cannot vouch for what does not exist on it", + }, + { + name: "denied_by_config beats blocked_by_user", + build: func() *world { + return healthyWorld().denyByConfig().approval(func(a *ApprovalState) { a.Disabled = true }) + }, + ref: ToolRef{ID: id}, + want: ReasonToolDeniedByConfig, + reason: "operator config is not user-overridable, so it is the actionable lock", + }, + { + name: "blocked_by_user beats tool_changed", + build: func() *world { + return healthyWorld().approval(func(a *ApprovalState) { + a.Disabled = true + a.Status = ApprovalStatusChanged + }) + }, + ref: ToolRef{ID: id}, + want: ReasonToolBlockedByUser, + reason: "approving the change would still leave the tool disabled", + }, + { + name: "tool_changed is not collapsed into tool_pending_approval", + build: func() *world { + return healthyWorld().approval(func(a *ApprovalState) { a.Status = ApprovalStatusChanged }) + }, + ref: ToolRef{ID: id}, + want: ReasonToolChanged, + reason: "a status field holds one value; the pair encodes changed != pending (research D2)", + }, + { + name: "pending beats hash_mismatch", + build: func() *world { + return healthyWorld().approval(func(a *ApprovalState) { a.Status = ApprovalStatusPending }) + }, + ref: ToolRef{ID: id, PinHash: "sha256/v2:deadbeef"}, + want: ReasonToolPendingApproval, + reason: "the pin cannot be re-locked before the tool is reviewed", + }, + { + name: "hash_mismatch beats oauth_required", + build: func() *world { return healthyWorld().runtime(RuntimeStatePendingAuth) }, + ref: ToolRef{ID: id, PinHash: "sha256/v2:deadbeef"}, + want: ReasonHashMismatch, + reason: "logging in will not make a drifted definition match the pin", + }, + { + name: "oauth_required beats server_unhealthy (PendingAuth is not a health failure)", + build: func() *world { return healthyWorld().runtime(RuntimeStatePendingAuth) }, + ref: ToolRef{ID: id}, + want: ReasonOAuthRequired, + reason: "FR-007: deferred OAuth is non-retryable and needs a login, not a wait", + }, + { + name: "server_unhealthy beats server_initializing", + build: func() *world { return healthyWorld().runtime(RuntimeStateError) }, + ref: ToolRef{ID: id}, + want: ReasonServerUnhealthy, + reason: "one connection state per server; error is reported as error, not as startup", + }, + { + name: "server_initializing beats the annotation filters", + build: func() *world { + w := healthyWorld().annotations(nil).runtime(RuntimeStateConnecting) + w.filters.readOnlyOnly = true + return w + }, + ref: ToolRef{ID: id}, + want: ReasonServerInitializing, + reason: "annotations from a half-connected server are not yet a policy verdict", + }, + { + name: "annotation filters beat ready", + build: func() *world { + w := healthyWorld().annotations(&config.ToolAnnotations{ReadOnlyHint: boolPtr(false)}) + w.filters.readOnlyOnly = true + return w + }, + ref: ToolRef{ID: id}, + want: ReasonPolicyFiltered, + reason: "the final gate before ready", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := evalOne(t, tt.build(), tt.ref) + assert.Equal(t, tt.want, res.Reason, tt.reason) + }) + } +} + +// The first annotation filter that excludes owns the omission (spec 094 order: +// read_only_only -> exclude_destructive -> exclude_open_world). +func TestEvaluate_AnnotationFilterOrder(t *testing.T) { + w := healthyWorld().annotations(nil) + w.filters.readOnlyOnly = true + w.filters.excludeDestructive = true + w.filters.excludeOpenWorld = true + + res := evalOne(t, w, ToolRef{ID: id}) + assert.Equal(t, ReasonMissingAnnotation, res.Reason) + assert.Contains(t, res.Detail, "read_only_only", "the first filter owns the omission") + + // With the read-only filter satisfied, the destructive filter owns it. + w2 := healthyWorld().annotations(&config.ToolAnnotations{ReadOnlyHint: boolPtr(true), DestructiveHint: boolPtr(true)}) + w2.filters.excludeDestructive = true + w2.filters.excludeOpenWorld = true + res2 := evalOne(t, w2, ToolRef{ID: id}) + // readOnlyHint=true is inherently non-destructive (frozen spec-094 + // shortcut), so exclude_open_world owns this one. + assert.Equal(t, ReasonMissingAnnotation, res2.Reason) + assert.Contains(t, res2.Detail, "exclude_open_world") +} + +// --- hash pins (FR-011) ----------------------------------------------------- + +func TestPin_FormatAndParseRoundTrip(t *testing.T) { + pin := FormatPin(2, "9f2c41ab") + assert.Equal(t, "sha256/v2:9f2c41ab", pin) + + v, h, err := ParsePin(pin) + require.NoError(t, err) + assert.Equal(t, uint64(2), v) + assert.Equal(t, "9f2c41ab", h) + + for _, bad := range []string{"", "abc123", "sha256:abc", "sha256/v:abc", "sha256/vX:abc", "sha256/v2:"} { + _, _, err := ParsePin(bad) + assert.Error(t, err, "pin %q must not parse", bad) + } +} + +func TestEvaluate_PinMatchingIsReady(t *testing.T) { + res := evalOne(t, healthyWorld(), ToolRef{ID: id, PinHash: "sha256/v2:abc123"}) + assert.Equal(t, StatusReady, res.Status) +} + +func TestEvaluate_PinSchemaVersionBumpIsDistinguishable(t *testing.T) { + // Same hex, different schema version: a proxy-side algorithm bump, not + // upstream drift. Same reason code, different detail (research D4). + res := evalOne(t, healthyWorld(), ToolRef{ID: id, PinHash: "sha256/v1:abc123"}) + assert.Equal(t, ReasonHashMismatch, res.Reason) + assert.Contains(t, res.Detail, "Hash schema changed") + + drift := evalOne(t, healthyWorld(), ToolRef{ID: id, PinHash: "sha256/v2:deadbeef"}) + assert.Equal(t, ReasonHashMismatch, drift.Reason) + assert.NotContains(t, drift.Detail, "Hash schema changed") +} + +func TestEvaluate_PinUnverifiableFailsClosed(t *testing.T) { + // No stored hash at all: a pin that cannot be checked must not pass a gate + // whose entire purpose is detecting drift. + w := healthyWorld().approval(func(a *ApprovalState) { a.CurrentHash = "" }) + res := evalOne(t, w, ToolRef{ID: id, PinHash: "sha256/v2:abc123"}) + assert.Equal(t, ReasonHashMismatch, res.Reason) + assert.Contains(t, res.Detail, "cannot be verified") + + malformed := evalOne(t, healthyWorld(), ToolRef{ID: id, PinHash: "not-a-pin"}) + assert.Equal(t, ReasonHashMismatch, malformed.Reason) + assert.Contains(t, malformed.Detail, "Invalid pin format") +} + +func TestEvaluate_PinsMapIsAFallbackForRefPin(t *testing.T) { + w := healthyWorld() + w.pins = map[string]string{id: "sha256/v2:deadbeef"} + assert.Equal(t, ReasonHashMismatch, evalOne(t, w, ToolRef{ID: id}).Reason) + + // An explicit ref pin wins over the map. + assert.Equal(t, StatusReady, evalOne(t, w, ToolRef{ID: id, PinHash: "sha256/v2:abc123"}).Status) +} + +// --- ids, batching, infra errors ------------------------------------------- + +func TestEvaluate_MalformedIDIsPerIDNotRequestError(t *testing.T) { + w := healthyWorld() + results, err := Evaluate(context.Background(), w.ctx(), []ToolRef{ + {ID: "no-separator"}, + {ID: id}, + {ID: ":empty-server"}, + {ID: "empty-tool:"}, + }) + require.NoError(t, err, "one bad entry must not mask the rest") + require.Len(t, results, 4) + + assert.Equal(t, ReasonNotFound, results[0].Reason) + assert.Contains(t, results[0].Detail, ":") + assert.Equal(t, StatusReady, results[1].Status) + assert.Equal(t, ReasonNotFound, results[2].Reason) + assert.Equal(t, ReasonNotFound, results[3].Reason) +} + +func TestEvaluate_ResultsFollowRequestOrderAndEchoIDs(t *testing.T) { + w := healthyWorld() + refs := []ToolRef{{ID: "gh:missing"}, {ID: id}, {ID: "ghost:tool"}} + results, err := Evaluate(context.Background(), w.ctx(), refs) + require.NoError(t, err) + require.Len(t, results, 3) + for i, ref := range refs { + assert.Equal(t, ref.ID, results[i].ID) + } +} + +// FR-006 / doc invariant 2: an infrastructure read failure is an error, never a +// fabricated reason code. +func TestEvaluate_InfraErrorsAreErrorsNotReasons(t *testing.T) { + t.Run("index read", func(t *testing.T) { + w := healthyWorld() + w.index.toolsErr = errBoom + _, err := Evaluate(context.Background(), w.ctx(), []ToolRef{{ID: id}}) + require.Error(t, err) + }) + t.Run("approval read", func(t *testing.T) { + w := healthyWorld() + w.approvals.err = errBoom + _, err := Evaluate(context.Background(), w.ctx(), []ToolRef{{ID: id}}) + require.Error(t, err) + }) + t.Run("server policy read", func(t *testing.T) { + w := healthyWorld() + w.policy.serverErr = errBoom + _, err := Evaluate(context.Background(), w.ctx(), []ToolRef{{ID: id}}) + require.Error(t, err) + }) + t.Run("tool config policy read", func(t *testing.T) { + w := healthyWorld() + w.policy.deniedErr = errBoom + _, err := Evaluate(context.Background(), w.ctx(), []ToolRef{{ID: id}}) + require.Error(t, err) + }) + t.Run("suggestion corpus read", func(t *testing.T) { + w := healthyWorld().unindex().forget() + w.index.serversErr = errBoom + _, err := Evaluate(context.Background(), w.ctx(), []ToolRef{{ID: id}}) + require.Error(t, err, "a failed corpus build must not silently produce a suggestion-free not_found") + }) +} + +func TestEvaluate_ContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := Evaluate(ctx, healthyWorld().ctx(), []ToolRef{{ID: id}}) + require.ErrorIs(t, err, context.Canceled) +} + +// A missing snapshot entry is not evidence of ill health: the evaluator makes no +// connection-state claim (the served surface refuses with 503 when the runtime +// is unavailable altogether). +func TestEvaluate_NoRuntimeEntryMakesNoConnectionClaim(t *testing.T) { + w := healthyWorld() + delete(w.state.states, srv) + assert.Equal(t, StatusReady, evalOne(t, w, ToolRef{ID: id}).Status) + + w2 := healthyWorld() + w2.state = nil + res, err := Evaluate(context.Background(), w2.ctx(), []ToolRef{{ID: id}}) + require.NoError(t, err) + assert.Equal(t, StatusReady, res[0].Status) +} + +// The spec-044 diagnostic may sharpen the server_unhealthy action, but only +// within the existing health vocabulary. +func TestEvaluate_UnhealthyActionOverride(t *testing.T) { + w := healthyWorld() + w.state.states[srv] = ServerRuntime{State: RuntimeStateError, Detail: "process exited: status 127", Action: "restart"} + res := evalOne(t, w, ToolRef{ID: id}) + assert.Equal(t, ReasonServerUnhealthy, res.Reason) + assert.Equal(t, "restart", res.Action) + assert.Equal(t, "process exited: status 127", res.Detail) + + w2 := healthyWorld() + w2.state.states[srv] = ServerRuntime{State: RuntimeStateError, Action: "sacrifice_a_goat"} + assert.Equal(t, "view_logs", evalOne(t, w2, ToolRef{ID: id}).Action, + "an action outside the health vocabulary falls back to the taxonomy default") +} + +// The evaluator's tool-level verdicts must be exactly the shared classifier's, +// so preflight and dispatch cannot disagree (FR-002). +func TestEvaluate_UsesSharedClassifier(t *testing.T) { + // auto_approve_tool_changes: a changed tool is ready (dispatch behavior). + w := healthyWorld().autoApprove().approval(func(a *ApprovalState) { a.Status = ApprovalStatusChanged }) + assert.Equal(t, StatusReady, evalOne(t, w, ToolRef{ID: id}).Status) + + // Global quarantine off: pending does not gate either. + w2 := healthyWorld().approval(func(a *ApprovalState) { a.Status = ApprovalStatusPending }) + w2.policy.quarantine = false + assert.Equal(t, StatusReady, evalOne(t, w2, ToolRef{ID: id}).Status) + + // But a user block still applies in both cases. + w3 := healthyWorld().autoApprove().approval(func(a *ApprovalState) { a.Disabled = true }) + assert.Equal(t, ReasonToolBlockedByUser, evalOne(t, w3, ToolRef{ID: id}).Reason) +} diff --git a/internal/preflight/fakes_test.go b/internal/preflight/fakes_test.go new file mode 100644 index 00000000..494e9c1f --- /dev/null +++ b/internal/preflight/fakes_test.go @@ -0,0 +1,216 @@ +package preflight + +import ( + "errors" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// --- fake readers ----------------------------------------------------------- +// +// The fakes exist to prove the evaluator needs nothing else: four in-memory +// maps are a complete world. If a future change makes the evaluator reach for +// an upstream client, a runtime or an index writer, these fakes stop compiling +// — which is the point. + +type fakeIndex struct { + tools map[string][]IndexedTool + toolsErr error + serversErr error + // serverOrder makes IndexedServerNames deterministic. + serverOrder []string +} + +func (f *fakeIndex) ToolsByServer(serverName string) ([]IndexedTool, error) { + if f.toolsErr != nil { + return nil, f.toolsErr + } + return f.tools[serverName], nil +} + +func (f *fakeIndex) IndexedServerNames() ([]string, error) { + if f.serversErr != nil { + return nil, f.serversErr + } + if f.serverOrder != nil { + return f.serverOrder, nil + } + names := make([]string, 0, len(f.tools)) + for name := range f.tools { + names = append(names, name) + } + return names, nil +} + +type fakeApprovals struct { + records map[string]*ApprovalState // key "server:tool" + err error +} + +func (f *fakeApprovals) ToolApproval(serverName, toolName string) (*ApprovalState, error) { + if f.err != nil { + return nil, f.err + } + return f.records[serverName+":"+toolName], nil +} + +type fakeState struct { + states map[string]ServerRuntime +} + +func (f *fakeState) ServerRuntime(serverName string) (ServerRuntime, bool) { + rt, ok := f.states[serverName] + return rt, ok +} + +type fakePolicy struct { + servers map[string]ServerPolicy + denied map[string]bool // key "server:tool" + quarantine bool + serverErr error + deniedErr error +} + +func (f *fakePolicy) ServerPolicy(serverName string) (ServerPolicy, error) { + if f.serverErr != nil { + return ServerPolicy{}, f.serverErr + } + return f.servers[serverName], nil +} + +func (f *fakePolicy) ToolConfigDenied(serverName, toolName string) (bool, error) { + if f.deniedErr != nil { + return false, f.deniedErr + } + return f.denied[serverName+":"+toolName], nil +} + +func (f *fakePolicy) QuarantineEnabled() bool { return f.quarantine } + +var errBoom = errors.New("boom") + +// --- world builder ---------------------------------------------------------- + +// world is a mutable fixture: start from healthyWorld() (one enabled, connected +// server with one indexed, approved tool) and sabotage exactly the axis under +// test. Every reason cell in the tests below is one mutation away from ready, +// which is what keeps the co-occurrence cases honest. +type world struct { + index *fakeIndex + approvals *fakeApprovals + state *fakeState + policy *fakePolicy + tier Tier + scope *Scope + filters filterSet + pins map[string]string +} + +// filterSet is an alias-free local mirror of the annotation filters so tests +// read declaratively. +type filterSet struct { + readOnlyOnly bool + excludeDestructive bool + excludeOpenWorld bool +} + +const ( + srv = "gh" + tool = "sync" + id = "gh:sync" +) + +func boolPtr(b bool) *bool { return &b } + +func healthyWorld() *world { + return &world{ + index: &fakeIndex{ + serverOrder: []string{srv}, + tools: map[string][]IndexedTool{ + srv: {{ + Name: srv + ":" + tool, + Annotations: &config.ToolAnnotations{ + ReadOnlyHint: boolPtr(true), + DestructiveHint: boolPtr(false), + OpenWorldHint: boolPtr(false), + }, + }}, + }, + }, + approvals: &fakeApprovals{records: map[string]*ApprovalState{ + id: {Status: ApprovalStatusApproved, CurrentHash: "abc123", HashSchemaVersion: 2}, + }}, + state: &fakeState{states: map[string]ServerRuntime{srv: {State: RuntimeStateReady}}}, + policy: &fakePolicy{servers: map[string]ServerPolicy{srv: {Found: true, Enabled: true}}, quarantine: true, denied: map[string]bool{}}, + tier: TierOperator, + } +} + +func (w *world) ctx() EvalContext { + ec := EvalContext{ + Index: w.index, + Approvals: w.approvals, + Policy: w.policy, + Tier: w.tier, + Scope: w.scope, + Pins: w.pins, + } + // Assigned conditionally so a nil *fakeState becomes a nil INTERFACE, the + // shape the glue produces when no runtime is wired. + if w.state != nil { + ec.State = w.state + } + ec.Filters.ReadOnlyOnly = w.filters.readOnlyOnly + ec.Filters.ExcludeDestructive = w.filters.excludeDestructive + ec.Filters.ExcludeOpenWorld = w.filters.excludeOpenWorld + return ec +} + +// server mutators +func (w *world) unconfigure() *world { delete(w.policy.servers, srv); return w } +func (w *world) quarantine() *world { + sp := w.policy.servers[srv] + sp.Quarantined = true + w.policy.servers[srv] = sp + return w +} +func (w *world) disable() *world { + sp := w.policy.servers[srv] + sp.Enabled = false + w.policy.servers[srv] = sp + return w +} +func (w *world) autoApprove() *world { + sp := w.policy.servers[srv] + sp.AutoApproveToolChanges = true + w.policy.servers[srv] = sp + return w +} +func (w *world) outOfScope() *world { + w.scope = NewScope("readonly", []string{"other"}) + return w +} +func (w *world) runtime(state ServerRuntimeState) *world { + w.state.states[srv] = ServerRuntime{State: state} + return w +} + +// tool mutators +func (w *world) unindex() *world { w.index.tools[srv] = nil; return w } +func (w *world) forget() *world { delete(w.approvals.records, id); return w } +func (w *world) denyByConfig() *world { w.policy.denied[id] = true; return w } +func (w *world) approval(mut func(*ApprovalState)) *world { + rec := w.approvals.records[id] + if rec == nil { + rec = &ApprovalState{} + w.approvals.records[id] = rec + } + mut(rec) + return w +} +func (w *world) annotations(a *config.ToolAnnotations) *world { + tools := w.index.tools[srv] + tools[0].Annotations = a + w.index.tools[srv] = tools + return w +} diff --git a/internal/preflight/reasons.go b/internal/preflight/reasons.go new file mode 100644 index 00000000..0a4877cf --- /dev/null +++ b/internal/preflight/reasons.go @@ -0,0 +1,293 @@ +package preflight + +import "github.com/smart-mcp-proxy/mcpproxy-go/internal/health" + +// Status is the per-tool outcome. `ready` is a success status, not a failure +// reason — a ready result carries no reason, retryable or action field. +type Status = string + +const ( + StatusReady Status = "ready" + StatusUnavailable Status = "unavailable" +) + +// Reason is the closed v1 failure-reason enum (Spec 098 FR-003). Evolution is +// additive-only and consumers must treat an unknown code as non-retryable. +// +// `server_saturated` is RESERVED (spec 093 queue saturation) and deliberately +// not defined here: defining it would put it in AllReasons and therefore in the +// generated wire enum, promising a verdict nothing emits. +type Reason = string + +const ( + ReasonServerInitializing Reason = "server_initializing" + ReasonServerUnhealthy Reason = "server_unhealthy" + ReasonServerDisabled Reason = "server_disabled" + ReasonServerQuarantined Reason = "server_quarantined" + ReasonToolPendingApproval Reason = "tool_pending_approval" + ReasonToolChanged Reason = "tool_changed" + ReasonToolBlockedByUser Reason = "tool_blocked_by_user" + ReasonOAuthRequired Reason = "oauth_required" + ReasonHashMismatch Reason = "hash_mismatch" + ReasonServerNotInScope Reason = "server_not_in_scope" + ReasonToolDeniedByConfig Reason = "tool_denied_by_config" + ReasonMissingAnnotation Reason = "missing_annotation" + ReasonPolicyFiltered Reason = "policy_filtered" + ReasonNotFound Reason = "not_found" + ReasonServerNotConfigured Reason = "server_not_configured" +) + +// Class groups reasons by what an operator must do about them. It drives the +// set verdict and therefore the CLI exit code. +type Class = string + +const ( + // ClassRetryable: waiting may fix it (the proxy is mid-transition). + ClassRetryable Class = "retryable" + // ClassFixState: an operator action on live state fixes it (approve, + // enable, log in, re-lock a pin). + ClassFixState Class = "fix_state" + // ClassPermanentConfig: nothing changes until configuration or the request + // itself changes. + ClassPermanentConfig Class = "permanent" +) + +// Verdict is the set-level aggregate: the worst class present. +type Verdict = string + +const ( + VerdictReady Verdict = "ready" + VerdictDegradedRetryable Verdict = "degraded_retryable" + VerdictBlocked Verdict = "blocked" + VerdictUnknownIDs Verdict = "unknown_ids" +) + +// CLI exit codes (FR-009). Worst class present wins: 12 > 11 > 10 > 0. +const ( + ExitReady = 0 + ExitDegradedRetryable = 10 + ExitBlocked = 11 + ExitUnknownIDs = 12 +) + +// reasonSpec is one row of the normative FR-003 table. Keeping the columns in +// one literal is what makes the table auditable against the spec by eye. +type reasonSpec struct { + class Class + retryable bool + // action uses the existing health-action vocabulary. "No action" is the + // empty string, which serializers omit — matching the health constants + // (health.ActionNone), not a literal "none". + action string + verdict Verdict + exitCode int + remediation string +} + +// reasonTable is the single source of truth for the FR-003 taxonomy. +var reasonTable = map[Reason]reasonSpec{ + ReasonServerInitializing: { + class: ClassRetryable, retryable: true, action: health.ActionNone, + verdict: VerdictDegradedRetryable, exitCode: ExitDegradedRetryable, + remediation: "Server is still starting up; retry shortly.", + }, + ReasonServerUnhealthy: { + class: ClassRetryable, retryable: true, action: health.ActionViewLogs, + verdict: VerdictDegradedRetryable, exitCode: ExitDegradedRetryable, + remediation: "Check the server's logs (mcpproxy upstream logs ) and retry once it recovers.", + }, + ReasonServerDisabled: { + class: ClassFixState, retryable: false, action: health.ActionEnable, + verdict: VerdictBlocked, exitCode: ExitBlocked, + remediation: "Enable the server (mcpproxy upstream enable ).", + }, + ReasonServerQuarantined: { + class: ClassFixState, retryable: false, action: health.ActionApprove, + verdict: VerdictBlocked, exitCode: ExitBlocked, + remediation: "Review the quarantined server and approve it if it is trusted (Web UI, Quarantine).", + }, + ReasonToolPendingApproval: { + class: ClassFixState, retryable: false, action: health.ActionApprove, + verdict: VerdictBlocked, exitCode: ExitBlocked, + remediation: "Review and approve the tool (Web UI, Server detail -> Tools).", + }, + ReasonToolChanged: { + class: ClassFixState, retryable: false, action: health.ActionApprove, + verdict: VerdictBlocked, exitCode: ExitBlocked, + remediation: "The tool definition changed after approval; review the diff and re-approve it (Web UI, Server detail -> Tools).", + }, + ReasonToolBlockedByUser: { + class: ClassFixState, retryable: false, action: health.ActionEnable, + verdict: VerdictBlocked, exitCode: ExitBlocked, + remediation: "Re-enable the tool (Web UI, Server detail -> Tools).", + }, + ReasonOAuthRequired: { + class: ClassFixState, retryable: false, action: health.ActionLogin, + verdict: VerdictBlocked, exitCode: ExitBlocked, + remediation: "Log in to the server (mcpproxy upstream login ); waiting will not help.", + }, + ReasonHashMismatch: { + class: ClassFixState, retryable: false, action: health.ActionConfigure, + verdict: VerdictBlocked, exitCode: ExitBlocked, + remediation: "Review the tool's current definition and re-pin it with the current hash.", + }, + ReasonServerNotInScope: { + class: ClassPermanentConfig, retryable: false, action: health.ActionConfigure, + verdict: VerdictBlocked, exitCode: ExitBlocked, + remediation: "Add the server to the profile, or run the preflight without the profile.", + }, + ReasonToolDeniedByConfig: { + class: ClassPermanentConfig, retryable: false, action: health.ActionConfigure, + verdict: VerdictBlocked, exitCode: ExitBlocked, + remediation: "Operator policy (enabled_tools/disabled_tools) denies this tool; edit mcp_config.json to allow it.", + }, + ReasonMissingAnnotation: { + class: ClassPermanentConfig, retryable: false, action: health.ActionConfigure, + verdict: VerdictBlocked, exitCode: ExitBlocked, + remediation: "The upstream tool lacks the annotation this filter requires; publish annotations upstream or drop the filter.", + }, + ReasonPolicyFiltered: { + class: ClassPermanentConfig, retryable: false, action: health.ActionNone, + verdict: VerdictBlocked, exitCode: ExitBlocked, + remediation: "The tool is explicitly marked unsafe for this filter; drop the filter to use it.", + }, + ReasonNotFound: { + class: ClassPermanentConfig, retryable: false, action: health.ActionConfigure, + verdict: VerdictUnknownIDs, exitCode: ExitUnknownIDs, + remediation: "Check the tool id (format :) against mcpproxy tools list.", + }, + ReasonServerNotConfigured: { + class: ClassPermanentConfig, retryable: false, action: health.ActionConfigure, + verdict: VerdictUnknownIDs, exitCode: ExitUnknownIDs, + remediation: "Add the server to mcp_config.json (mcpproxy upstream add), or fix the id.", + }, +} + +// Precedence is the fixed FR-004 chain: for one ID, the first reason in this +// order that applies wins. It covers every enum member exactly once — the two +// annotation reasons sit in the final "annotation filters" slot, where +// missing_annotation and policy_filtered are mutually exclusive per owning +// filter and therefore unordered relative to each other in practice. +var Precedence = []Reason{ + ReasonServerNotConfigured, + ReasonServerNotInScope, + ReasonServerQuarantined, + ReasonServerDisabled, + ReasonNotFound, + ReasonToolDeniedByConfig, + ReasonToolBlockedByUser, + ReasonToolChanged, + ReasonToolPendingApproval, + ReasonHashMismatch, + ReasonOAuthRequired, + ReasonServerUnhealthy, + ReasonServerInitializing, + ReasonMissingAnnotation, + ReasonPolicyFiltered, +} + +// AllReasons returns the enum in precedence order (a stable, meaningful order +// for docs and tests). The slice is a copy; callers may not mutate Precedence. +func AllReasons() []Reason { + out := make([]Reason, len(Precedence)) + copy(out, Precedence) + return out +} + +// ValidReason reports whether code is a member of the closed v1 enum. +func ValidReason(code Reason) bool { + _, ok := reasonTable[code] + return ok +} + +// ReasonClass returns the remediation class of a reason. Unknown codes are +// treated as permanent-config, matching the documented consumer rule ("treat +// unknown codes as non-retryable"). +func ReasonClass(code Reason) Class { + if spec, ok := reasonTable[code]; ok { + return spec.class + } + return ClassPermanentConfig +} + +// Retryable reports whether waiting can plausibly clear the reason. Unknown +// codes are non-retryable. +func Retryable(code Reason) bool { + spec, ok := reasonTable[code] + return ok && spec.retryable +} + +// DefaultAction returns the health-vocabulary action for a reason, or "" when +// the reason has no action (the field is then omitted from the response). +// server_unhealthy's action is a best-effort default that the evaluator may +// override from spec 044 diagnostics. +func DefaultAction(code Reason) string { + return reasonTable[code].action +} + +// DefaultRemediation returns the one actionable instruction for a reason. +func DefaultRemediation(code Reason) string { + return reasonTable[code].remediation +} + +// ReasonVerdict maps one reason to the set verdict it forces on its own. +func ReasonVerdict(code Reason) Verdict { + if spec, ok := reasonTable[code]; ok { + return spec.verdict + } + // An unknown code must never silently downgrade the verdict: treat it as + // blocked (non-retryable, operator action needed). + return VerdictBlocked +} + +// verdictRank orders verdicts from best to worst; the aggregate is the max. +var verdictRank = map[Verdict]int{ + VerdictReady: 0, + VerdictDegradedRetryable: 1, + VerdictBlocked: 2, + VerdictUnknownIDs: 3, +} + +// ExitCode maps a set verdict to the CLI exit code (FR-009). +func ExitCode(v Verdict) int { + switch v { + case VerdictUnknownIDs: + return ExitUnknownIDs + case VerdictBlocked: + return ExitBlocked + case VerdictDegradedRetryable: + return ExitDegradedRetryable + case VerdictReady: + return ExitReady + default: + // Unknown verdicts must not look like success. + return ExitBlocked + } +} + +// VerdictForReasons aggregates per-tool reasons into the set verdict: the worst +// class present. An empty list (every tool ready) is VerdictReady. +func VerdictForReasons(reasons []Reason) Verdict { + worst := VerdictReady + for _, r := range reasons { + if v := ReasonVerdict(r); verdictRank[v] > verdictRank[worst] { + worst = v + } + } + return worst +} + +// VerdictForResults is VerdictForReasons over evaluator results, ignoring ready +// entries. +func VerdictForResults(results []Result) Verdict { + worst := VerdictReady + for i := range results { + if results[i].Status == StatusReady { + continue + } + if v := ReasonVerdict(results[i].Reason); verdictRank[v] > verdictRank[worst] { + worst = v + } + } + return worst +} diff --git a/internal/preflight/reasons_test.go b/internal/preflight/reasons_test.go new file mode 100644 index 00000000..a9680f35 --- /dev/null +++ b/internal/preflight/reasons_test.go @@ -0,0 +1,181 @@ +package preflight + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fr003Row transcribes one row of the NORMATIVE FR-003 table (spec.md). +// This table is locked: if the implementation disagrees, the implementation is +// wrong. Adding an enum code without adding its row here fails +// TestReasonTable_CoversExactlyTheClosedEnum. +type fr003Row struct { + reason Reason + class Class + retryable bool + action string // "" == the action field is OMITTED + verdict Verdict + exit int +} + +var fr003Table = []fr003Row{ + {ReasonServerInitializing, ClassRetryable, true, "", VerdictDegradedRetryable, 10}, + {ReasonServerUnhealthy, ClassRetryable, true, "view_logs", VerdictDegradedRetryable, 10}, + {ReasonServerDisabled, ClassFixState, false, "enable", VerdictBlocked, 11}, + {ReasonServerQuarantined, ClassFixState, false, "approve", VerdictBlocked, 11}, + {ReasonToolPendingApproval, ClassFixState, false, "approve", VerdictBlocked, 11}, + {ReasonToolChanged, ClassFixState, false, "approve", VerdictBlocked, 11}, + {ReasonToolBlockedByUser, ClassFixState, false, "enable", VerdictBlocked, 11}, + {ReasonOAuthRequired, ClassFixState, false, "login", VerdictBlocked, 11}, + {ReasonHashMismatch, ClassFixState, false, "configure", VerdictBlocked, 11}, + {ReasonServerNotInScope, ClassPermanentConfig, false, "configure", VerdictBlocked, 11}, + {ReasonToolDeniedByConfig, ClassPermanentConfig, false, "configure", VerdictBlocked, 11}, + {ReasonMissingAnnotation, ClassPermanentConfig, false, "configure", VerdictBlocked, 11}, + {ReasonPolicyFiltered, ClassPermanentConfig, false, "", VerdictBlocked, 11}, + {ReasonNotFound, ClassPermanentConfig, false, "configure", VerdictUnknownIDs, 12}, + {ReasonServerNotConfigured, ClassPermanentConfig, false, "configure", VerdictUnknownIDs, 12}, +} + +func TestFR003Table(t *testing.T) { + require.Len(t, fr003Table, 15, "the v1 enum is exactly 15 codes") + + for _, row := range fr003Table { + t.Run(row.reason, func(t *testing.T) { + assert.True(t, ValidReason(row.reason), "reason must be a member of the closed enum") + assert.Equal(t, row.class, ReasonClass(row.reason), "class") + assert.Equal(t, row.retryable, Retryable(row.reason), "retryable") + assert.Equal(t, row.action, DefaultAction(row.reason), + "default action (empty string == field omitted, per the health constants)") + assert.Equal(t, row.verdict, ReasonVerdict(row.reason), "set verdict") + assert.Equal(t, row.exit, ExitCode(ReasonVerdict(row.reason)), "CLI exit code") + assert.NotEmpty(t, DefaultRemediation(row.reason), "every reason carries one actionable instruction") + }) + } +} + +// The enum is closed: the implementation table and the spec table must contain +// exactly the same members, so a new code cannot ship without its spec row. +func TestReasonTable_CoversExactlyTheClosedEnum(t *testing.T) { + spec := map[Reason]bool{} + for _, row := range fr003Table { + spec[row.reason] = true + } + impl := map[Reason]bool{} + for code := range reasonTable { + impl[code] = true + } + assert.Equal(t, spec, impl, "reasons.go and the FR-003 spec table must be identical sets") + + assert.Len(t, AllReasons(), 15) + inPrecedence := map[Reason]int{} + for _, r := range Precedence { + inPrecedence[r]++ + } + for code := range impl { + assert.Equal(t, 1, inPrecedence[code], "%s must appear exactly once in the precedence chain", code) + } + assert.Len(t, Precedence, 15, "precedence covers the whole enum") +} + +// TestPrecedence_ExactOrder pins the FR-004 chain verbatim. +func TestPrecedence_ExactOrder(t *testing.T) { + want := []Reason{ + "server_not_configured", + "server_not_in_scope", + "server_quarantined", + "server_disabled", + "not_found", + "tool_denied_by_config", + "tool_blocked_by_user", + "tool_changed", + "tool_pending_approval", + "hash_mismatch", + "oauth_required", + "server_unhealthy", + "server_initializing", + "missing_annotation", + "policy_filtered", + } + assert.Equal(t, want, Precedence) +} + +// The enum values are a wire contract: a rename is a breaking change. +func TestReasonWireValues(t *testing.T) { + assert.Equal(t, "server_initializing", ReasonServerInitializing) + assert.Equal(t, "server_unhealthy", ReasonServerUnhealthy) + assert.Equal(t, "server_disabled", ReasonServerDisabled) + assert.Equal(t, "server_quarantined", ReasonServerQuarantined) + assert.Equal(t, "tool_pending_approval", ReasonToolPendingApproval) + assert.Equal(t, "tool_changed", ReasonToolChanged) + assert.Equal(t, "tool_blocked_by_user", ReasonToolBlockedByUser) + assert.Equal(t, "oauth_required", ReasonOAuthRequired) + assert.Equal(t, "hash_mismatch", ReasonHashMismatch) + assert.Equal(t, "server_not_in_scope", ReasonServerNotInScope) + assert.Equal(t, "tool_denied_by_config", ReasonToolDeniedByConfig) + assert.Equal(t, "missing_annotation", ReasonMissingAnnotation) + assert.Equal(t, "policy_filtered", ReasonPolicyFiltered) + assert.Equal(t, "not_found", ReasonNotFound) + assert.Equal(t, "server_not_configured", ReasonServerNotConfigured) + + assert.Equal(t, "ready", StatusReady) + assert.Equal(t, "unavailable", StatusUnavailable) + assert.Equal(t, "ready", VerdictReady) + assert.Equal(t, "degraded_retryable", VerdictDegradedRetryable) + assert.Equal(t, "blocked", VerdictBlocked) + assert.Equal(t, "unknown_ids", VerdictUnknownIDs) +} + +// `server_saturated` is reserved but unimplemented: it must not be a member of +// the enum, or the wire contract would promise a verdict nothing emits. +func TestReservedCodeNotImplemented(t *testing.T) { + assert.False(t, ValidReason("server_saturated")) +} + +func TestVerdictAggregation_WorstClassWins(t *testing.T) { + tests := []struct { + name string + reasons []Reason + want Verdict + exit int + }{ + {"all ready", nil, VerdictReady, 0}, + {"only retryable", []Reason{ReasonServerInitializing, ReasonServerUnhealthy}, VerdictDegradedRetryable, 10}, + {"blocked beats retryable", []Reason{ReasonServerInitializing, ReasonToolChanged}, VerdictBlocked, 11}, + {"unknown id beats blocked", []Reason{ReasonToolChanged, ReasonNotFound}, VerdictUnknownIDs, 12}, + {"unknown id beats everything", []Reason{ReasonServerInitializing, ReasonToolChanged, ReasonServerNotConfigured}, VerdictUnknownIDs, 12}, + {"order independent", []Reason{ReasonNotFound, ReasonServerInitializing}, VerdictUnknownIDs, 12}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := VerdictForReasons(tt.reasons) + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.exit, ExitCode(got)) + }) + } +} + +func TestVerdictForResults_IgnoresReadyEntries(t *testing.T) { + results := []Result{ + {ID: "a:one", Status: StatusReady}, + {ID: "b:two", Status: StatusUnavailable, Reason: ReasonServerInitializing}, + {ID: "c:three", Status: StatusReady}, + } + assert.Equal(t, VerdictDegradedRetryable, VerdictForResults(results)) + + allReady := []Result{{ID: "a:one", Status: StatusReady}} + assert.Equal(t, VerdictReady, VerdictForResults(allReady)) + assert.Equal(t, 0, ExitCode(VerdictForResults(allReady))) +} + +// Unknown codes (a newer proxy talking to an older consumer) must degrade +// safely: non-retryable, blocked, never "ready". +func TestUnknownCodeDegradesSafely(t *testing.T) { + const future = "some_future_reason" + assert.False(t, ValidReason(future)) + assert.False(t, Retryable(future)) + assert.Equal(t, ClassPermanentConfig, ReasonClass(future)) + assert.Equal(t, VerdictBlocked, ReasonVerdict(future)) + assert.Equal(t, ExitBlocked, ExitCode("nonsense_verdict")) +} diff --git a/internal/preflight/request.go b/internal/preflight/request.go new file mode 100644 index 00000000..abc37f6b --- /dev/null +++ b/internal/preflight/request.go @@ -0,0 +1,58 @@ +package preflight + +import ( + "errors" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/toolannotations" +) + +// Sentinel errors the GLUE returns for conditions the served surface must map +// to a specific HTTP status. Everything else that comes back from a preflight is +// an infrastructure read failure (FR-006) and answers 503. +var ( + // ErrUnknownProfile means the request named a profile that is not + // configured. The served surface answers 400 (FR-010) — it is a caller + // mistake, not proxy state, and inventing a verdict for it would put a + // request bug into the reason taxonomy. + ErrUnknownProfile = errors.New("preflight: unknown profile") + // ErrRuntimeUnavailable means the process is too degraded to evaluate + // honestly (no storage, no index, no live config). The served surface + // answers 503 rather than emit reduced-fidelity verdicts (FR-006). + ErrRuntimeUnavailable = errors.New("preflight: runtime unavailable") +) + +// Params is one preflight request as the glue layer receives it: the caller's +// identity-derived inputs (tier, token scope, token profile pin) plus the +// request's own inputs (tool refs, profile, policy filters). +// +// It carries NAMES, not resolved scopes: resolving a profile slug to its server +// set requires the live config, which only the glue can read. That is also where +// an unknown profile becomes ErrUnknownProfile. +type Params struct { + // Tools are the requested refs, already deduplicated by the caller, in + // first-occurrence order. + Tools []ToolRef + // Tier selects the disclosure rules (FR-013). Defaults to TierOperator when + // empty — the caller must set TierAgentToken explicitly, so a new call site + // cannot accidentally get the more permissive disclosure. + Tier Tier + // Profile is the profile named in the request body ("" = unscoped operator + // view). + Profile string + // TokenServers is the agent token's allowed_servers list (nil for operator + // callers). + TokenServers []string + // TokenProfilePin is the profile an agent token is pinned to, propagated + // through REST auth (Spec 057 / review finding 11). It can only narrow the + // evaluation scope, never widen it. + TokenProfilePin string + // Filters are the caller's annotation policy filters (spec 094 semantics). + Filters toolannotations.Filters +} + +// Outcome is one evaluated preflight: the per-tool results in request order plus +// the set-level verdict derived from them. +type Outcome struct { + Verdict Verdict + Results []Result +} diff --git a/internal/preflight/scope.go b/internal/preflight/scope.go new file mode 100644 index 00000000..a35e32cb --- /dev/null +++ b/internal/preflight/scope.go @@ -0,0 +1,162 @@ +package preflight + +import "sort" + +// Tier is the caller's disclosure tier (FR-013). The operator tier sees the +// full diagnosis (including hashes and `server_not_in_scope`); the agent-token +// tier gets scope-silence — an out-of-scope id is byte-indistinguishable from +// an ordinary `not_found`. +type Tier = string + +const ( + TierOperator Tier = "operator" + TierAgentToken Tier = "agent_token" +) + +// Scope is the immutable set of upstream servers an evaluation may see. A nil +// *Scope means unrestricted (operator, no profile, no token scope) — the same +// nil-receiver convention profile.ProfileScope uses. +type Scope struct { + name string + servers map[string]struct{} +} + +// NewScope builds a named scope over an explicit server set. An empty (but +// non-nil) set is a legal deny-everything scope. +func NewScope(name string, servers []string) *Scope { + set := make(map[string]struct{}, len(servers)) + for _, s := range servers { + if s == "" { + continue + } + set[s] = struct{}{} + } + return &Scope{name: name, servers: set} +} + +// Allows reports whether the named server is visible under this scope. +func (s *Scope) Allows(serverName string) bool { + if s == nil { + return true + } + if serverName == "" { + return false + } + _, ok := s.servers[serverName] + return ok +} + +// Name returns the scope's label (a profile slug), or "" for an unnamed or +// unrestricted scope. It is used only in operator-tier `detail` text. +func (s *Scope) Name() string { + if s == nil { + return "" + } + return s.name +} + +// ServerNames returns the sorted member list, or nil for an unrestricted scope. +func (s *Scope) ServerNames() []string { + if s == nil { + return nil + } + out := make([]string, 0, len(s.servers)) + for name := range s.servers { + out = append(out, name) + } + sort.Strings(out) + return out +} + +// ScopeInputs are the three independent restrictions that compose an evaluation +// scope (Spec 098 FR-010/FR-013, review finding 11). Each is optional; the +// effective scope is their intersection. +// +// A nil slice means "no restriction from this source". An agent token's +// AllowedServers containing "*" is likewise unrestricted (matching +// auth.AuthContext.CanAccessServer). +type ScopeInputs struct { + // TokenServers is the agent token's allowed_servers list. nil for operator + // callers (API key / socket / named pipe). + TokenServers []string + // TokenPinName / TokenPinServers describe the profile an agent token is + // pinned to (auth.AgentToken.ProfilePin). Until this feature the pin was + // dropped on the REST path; it is now carried into evaluation. + TokenPinName string + TokenPinServers []string + // RequestedProfileName / RequestedProfileServers describe the profile the + // caller asked to evaluate under (`profile` in the request body). + RequestedProfileName string + RequestedProfileServers []string +} + +// ResolveScope computes the effective evaluation scope as +// token scope ∩ token pin ∩ requested profile. +// +// The scope's name is the most specific label available (requested profile, +// else token pin), which is what the operator-tier `server_not_in_scope` detail +// quotes. When a pinned token requests a DIFFERENT profile, the intersection is +// naturally the overlap of the two profiles — possibly empty, in which case +// every id resolves out of scope (and, at the agent-token tier, to `not_found`). +// The pin can therefore never be widened by naming another profile. +func ResolveScope(in ScopeInputs) *Scope { + restrictions := make([][]string, 0, 3) + if servers, restricted := normalizeTokenServers(in.TokenServers); restricted { + restrictions = append(restrictions, servers) + } + if in.TokenPinName != "" { + restrictions = append(restrictions, in.TokenPinServers) + } + if in.RequestedProfileName != "" { + restrictions = append(restrictions, in.RequestedProfileServers) + } + + name := in.RequestedProfileName + if name == "" { + name = in.TokenPinName + } + + if len(restrictions) == 0 { + return nil // unrestricted + } + + // Intersect: start from the first restriction, keep only members present in + // every other one. + members := make(map[string]struct{}, len(restrictions[0])) + for _, s := range restrictions[0] { + if s != "" { + members[s] = struct{}{} + } + } + for _, next := range restrictions[1:] { + allowed := make(map[string]struct{}, len(next)) + for _, s := range next { + allowed[s] = struct{}{} + } + for s := range members { + if _, ok := allowed[s]; !ok { + delete(members, s) + } + } + } + + names := make([]string, 0, len(members)) + for s := range members { + names = append(names, s) + } + return NewScope(name, names) +} + +// normalizeTokenServers reports the token's server restriction. A nil/empty +// list or a "*" wildcard entry means the token does not restrict servers. +func normalizeTokenServers(list []string) (servers []string, restricted bool) { + if len(list) == 0 { + return nil, false + } + for _, s := range list { + if s == "*" { + return nil, false + } + } + return list, true +} diff --git a/internal/preflight/scope_test.go b/internal/preflight/scope_test.go new file mode 100644 index 00000000..285b6903 --- /dev/null +++ b/internal/preflight/scope_test.go @@ -0,0 +1,168 @@ +package preflight + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestScope_NilIsUnrestricted(t *testing.T) { + var s *Scope + assert.True(t, s.Allows("anything")) + assert.Empty(t, s.Name()) + assert.Nil(t, s.ServerNames()) +} + +func TestScope_Membership(t *testing.T) { + s := NewScope("readonly", []string{"fs", "web"}) + assert.True(t, s.Allows("fs")) + assert.True(t, s.Allows("web")) + assert.False(t, s.Allows("github")) + assert.False(t, s.Allows(""), "the empty server name is never allowed by a real scope") + assert.Equal(t, "readonly", s.Name()) + assert.Equal(t, []string{"fs", "web"}, s.ServerNames()) + + deny := NewScope("empty", nil) + assert.False(t, deny.Allows("fs"), "an empty scope is a legal deny-everything scope") +} + +// ResolveScope: evaluation scope = token scope ∩ token pin ∩ requested profile. +func TestResolveScope_Intersections(t *testing.T) { + tests := []struct { + name string + in ScopeInputs + unrestricted bool + wantName string + allowed []string + denied []string + }{ + { + name: "operator, no profile: unrestricted", + in: ScopeInputs{}, + unrestricted: true, + }, + { + name: "agent token with wildcard scope and no pin: unrestricted", + in: ScopeInputs{TokenServers: []string{"*"}}, + unrestricted: true, + }, + { + name: "token scope only", + in: ScopeInputs{TokenServers: []string{"fs", "web"}}, + allowed: []string{"fs", "web"}, + denied: []string{"github"}, + wantName: "", + }, + { + name: "requested profile only", + in: ScopeInputs{ + RequestedProfileName: "readonly", + RequestedProfileServers: []string{"fs", "docs"}, + }, + allowed: []string{"fs", "docs"}, + denied: []string{"github"}, + wantName: "readonly", + }, + { + name: "token pin only (previously dropped on the REST path)", + in: ScopeInputs{ + TokenPinName: "work", + TokenPinServers: []string{"jira", "gh"}, + }, + allowed: []string{"jira", "gh"}, + denied: []string{"fs"}, + wantName: "work", + }, + { + name: "token scope ∩ pin", + in: ScopeInputs{ + TokenServers: []string{"gh", "fs"}, + TokenPinName: "work", + TokenPinServers: []string{"gh", "jira"}, + }, + allowed: []string{"gh"}, + denied: []string{"fs", "jira"}, + wantName: "work", + }, + { + name: "token scope ∩ pin ∩ requested profile", + in: ScopeInputs{ + TokenServers: []string{"gh", "fs", "jira"}, + TokenPinName: "work", + TokenPinServers: []string{"gh", "jira"}, + RequestedProfileName: "review", + RequestedProfileServers: []string{"gh", "fs"}, + }, + allowed: []string{"gh"}, + denied: []string{"fs", "jira"}, + wantName: "review", + }, + { + name: "a pinned token naming a disjoint profile can see nothing", + in: ScopeInputs{ + TokenPinName: "work", + TokenPinServers: []string{"gh"}, + RequestedProfileName: "personal", + RequestedProfileServers: []string{"fs"}, + }, + denied: []string{"gh", "fs"}, + wantName: "personal", + }, + { + name: "an empty pinned profile denies everything", + in: ScopeInputs{ + TokenServers: []string{"gh"}, + TokenPinName: "locked", + TokenPinServers: nil, + }, + denied: []string{"gh"}, + wantName: "locked", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveScope(tt.in) + if tt.unrestricted { + assert.Nil(t, got, "an unrestricted scope is the nil scope") + return + } + if assert.NotNil(t, got) { + assert.Equal(t, tt.wantName, got.Name()) + } + for _, s := range tt.allowed { + assert.True(t, got.Allows(s), "%s must be in scope", s) + } + for _, s := range tt.denied { + assert.False(t, got.Allows(s), "%s must be out of scope", s) + } + }) + } +} + +// The pin can never be widened by naming another profile: the result is the +// overlap, never the union. +func TestResolveScope_PinCannotBeWidened(t *testing.T) { + got := ResolveScope(ScopeInputs{ + TokenPinName: "work", + TokenPinServers: []string{"gh"}, + RequestedProfileName: "everything", + RequestedProfileServers: []string{"gh", "fs", "secrets"}, + }) + assert.Equal(t, []string{"gh"}, got.ServerNames()) +} + +func TestNormalizeTokenServers(t *testing.T) { + _, restricted := normalizeTokenServers(nil) + assert.False(t, restricted) + + _, restricted = normalizeTokenServers([]string{}) + assert.False(t, restricted) + + _, restricted = normalizeTokenServers([]string{"gh", "*"}) + assert.False(t, restricted, "a wildcard entry means the token does not restrict servers") + + servers, restricted := normalizeTokenServers([]string{"gh"}) + assert.True(t, restricted) + assert.Equal(t, []string{"gh"}, servers) +} diff --git a/internal/preflight/suggest.go b/internal/preflight/suggest.go new file mode 100644 index 00000000..f5c93279 --- /dev/null +++ b/internal/preflight/suggest.go @@ -0,0 +1,123 @@ +package preflight + +import ( + "sort" + "strings" +) + +// MaxSuggestions caps `did_you_mean` at three entries (FR-013): enough to fix a +// typo, too few to enumerate a corpus. +const MaxSuggestions = 3 + +// maxEditDistance is the Levenshtein budget for a near-miss. Two edits catches +// realistic typos (transposition, a dropped or doubled character, a wrong +// suffix) without turning the list into a fuzzy search. +const maxEditDistance = 2 + +// Suggest returns up to MaxSuggestions candidate ids nearest to want. +// +// Candidates MUST already be filtered to what the caller may see: in-scope, +// non-quarantined servers only (a quarantined server's tools are never +// suggested — FR-013 — and they are not indexed in the first place). The +// function itself performs no filtering and no I/O; it is pure string work, so +// the visibility decision stays in one place (the evaluator's corpus builder). +// +// Ranking: prefix relationships first (a truncated or over-typed id is the most +// common miss), then smaller edit distance, then lexicographic order for a +// deterministic response. Exact matches are never suggested — if the id existed +// the caller would not be seeing not_found. +func Suggest(want string, candidates []string) []string { + want = strings.TrimSpace(want) + if want == "" || len(candidates) == 0 { + return nil + } + + type scored struct { + id string + prefix bool + dist int + } + var hits []scored + seen := make(map[string]struct{}, len(candidates)) + for _, cand := range candidates { + if cand == "" || cand == want { + continue + } + if _, dup := seen[cand]; dup { + continue + } + seen[cand] = struct{}{} + + prefix := strings.HasPrefix(cand, want) || strings.HasPrefix(want, cand) + dist := levenshtein(want, cand, maxEditDistance) + if !prefix && dist > maxEditDistance { + continue + } + hits = append(hits, scored{id: cand, prefix: prefix, dist: dist}) + } + if len(hits) == 0 { + return nil + } + + sort.Slice(hits, func(i, j int) bool { + if hits[i].prefix != hits[j].prefix { + return hits[i].prefix + } + if hits[i].dist != hits[j].dist { + return hits[i].dist < hits[j].dist + } + return hits[i].id < hits[j].id + }) + + if len(hits) > MaxSuggestions { + hits = hits[:MaxSuggestions] + } + out := make([]string, 0, len(hits)) + for _, h := range hits { + out = append(out, h.id) + } + return out +} + +// levenshtein computes the edit distance between a and b, giving up (returning +// maxDist+1) once every cell of the current row exceeds maxDist. Operates on +// runes so multi-byte names are not scored by their UTF-8 length. +func levenshtein(a, b string, maxDist int) int { + ra, rb := []rune(a), []rune(b) + if len(ra) == 0 { + return len(rb) + } + if len(rb) == 0 { + return len(ra) + } + // Length difference alone already exceeds the budget. + if diff := len(ra) - len(rb); diff > maxDist || -diff > maxDist { + return maxDist + 1 + } + + prev := make([]int, len(rb)+1) + curr := make([]int, len(rb)+1) + for j := range prev { + prev[j] = j + } + + for i := 1; i <= len(ra); i++ { + curr[0] = i + rowMin := curr[0] + for j := 1; j <= len(rb); j++ { + cost := 1 + if ra[i-1] == rb[j-1] { + cost = 0 + } + curr[j] = min(curr[j-1]+1, prev[j]+1, prev[j-1]+cost) + if curr[j] < rowMin { + rowMin = curr[j] + } + } + if rowMin > maxDist { + return maxDist + 1 + } + prev, curr = curr, prev + } + return prev[len(rb)] +} diff --git a/internal/preflight/suggest_test.go b/internal/preflight/suggest_test.go new file mode 100644 index 00000000..0ee29591 --- /dev/null +++ b/internal/preflight/suggest_test.go @@ -0,0 +1,104 @@ +package preflight + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSuggest_PrefixAndNearMisses(t *testing.T) { + candidates := []string{ + "gh:create_issue", + "gh:create_issues", + "gh:close_issue", + "slack:post_message", + "fs:read_file", + } + + tests := []struct { + name string + want string + out []string + }{ + { + name: "one edit away, then two", + want: "gh:create_isue", + out: []string{"gh:create_issue", "gh:create_issues"}, + }, + { + name: "prefix of a longer id ranks first", + want: "gh:create_issue", + // exact matches are never suggested; the plural is a prefix extension + out: []string{"gh:create_issues"}, + }, + { + name: "no candidate within budget", + want: "gh:totally_different_tool", + out: nil, + }, + { + name: "empty query", + want: "", + out: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.out, Suggest(tt.want, candidates)) + }) + } +} + +func TestSuggest_CapsAtThreeAndIsDeterministic(t *testing.T) { + candidates := []string{"gh:aa", "gh:ab", "gh:ac", "gh:ad", "gh:ae"} + got := Suggest("gh:a", candidates) + assert.Len(t, got, MaxSuggestions) + assert.Equal(t, []string{"gh:aa", "gh:ab", "gh:ac"}, got, "ties break lexicographically for a stable response") + + // Repeated calls must not reorder. + assert.Equal(t, got, Suggest("gh:a", candidates)) +} + +func TestSuggest_NeverSuggestsTheExactID(t *testing.T) { + assert.Empty(t, Suggest("gh:sync", []string{"gh:sync"})) +} + +func TestSuggest_DeduplicatesCandidates(t *testing.T) { + got := Suggest("gh:syn", []string{"gh:sync", "gh:sync", "gh:sync"}) + assert.Equal(t, []string{"gh:sync"}, got) +} + +func TestLevenshtein_BudgetedDistance(t *testing.T) { + assert.Equal(t, 0, levenshtein("abc", "abc", 2)) + assert.Equal(t, 1, levenshtein("abc", "abd", 2)) + assert.Equal(t, 2, levenshtein("abc", "add", 2)) + assert.Greater(t, levenshtein("abc", "xyz", 2), 2, "over-budget distances give up early") + assert.Equal(t, 3, levenshtein("", "abc", 2), "an empty side is just the other length") + // Runes, not bytes: "café" -> "cafe" is ONE edit, though it is two bytes. + assert.Equal(t, 1, levenshtein("café", "cafe", 2)) +} + +// The evaluator wires suggestions into not_found from the caller-visible corpus. +func TestEvaluate_NotFoundCarriesSuggestions(t *testing.T) { + w := healthyWorld() + res := evalOne(t, w, ToolRef{ID: "gh:sunc"}) + assert.Equal(t, ReasonNotFound, res.Reason) + assert.Equal(t, []string{"gh:sync"}, res.DidYouMean) +} + +// A miss on a server with no near neighbours simply carries none. +func TestEvaluate_NotFoundWithoutSuggestions(t *testing.T) { + res := evalOne(t, healthyWorld(), ToolRef{ID: "gh:completely_unrelated"}) + assert.Equal(t, ReasonNotFound, res.Reason) + assert.Empty(t, res.DidYouMean) +} + +// Indexed names may carry the "server:tool" prefix or be bare; the corpus +// normalizes both to canonical ids. +func TestEvaluate_SuggestionCorpusNormalizesBareNames(t *testing.T) { + w := healthyWorld() + w.index.tools[srv] = []IndexedTool{{Name: tool}} // bare name in the index + res := evalOne(t, w, ToolRef{ID: "gh:sunc"}) + assert.Equal(t, []string{"gh:sync"}, res.DidYouMean) +} diff --git a/internal/preflight/tier_test.go b/internal/preflight/tier_test.go new file mode 100644 index 00000000..4259ed84 --- /dev/null +++ b/internal/preflight/tier_test.go @@ -0,0 +1,192 @@ +package preflight + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// twoServerWorld: "vis" is visible to the caller, "hidden" is configured and +// healthy but outside the scope. Both have exactly one indexed tool. +func twoServerWorld() *world { + w := &world{ + index: &fakeIndex{ + serverOrder: []string{"vis", "hidden"}, + tools: map[string][]IndexedTool{ + "vis": {{Name: "vis:alpha", Annotations: &config.ToolAnnotations{ReadOnlyHint: boolPtr(true)}}}, + "hidden": {{Name: "hidden:beta", Annotations: &config.ToolAnnotations{ReadOnlyHint: boolPtr(true)}}}, + }, + }, + approvals: &fakeApprovals{records: map[string]*ApprovalState{ + "vis:alpha": {Status: ApprovalStatusApproved, CurrentHash: "aaa", HashSchemaVersion: 2}, + "hidden:beta": {Status: ApprovalStatusApproved, CurrentHash: "bbb", HashSchemaVersion: 2}, + }}, + state: &fakeState{states: map[string]ServerRuntime{ + "vis": {State: RuntimeStateReady}, + "hidden": {State: RuntimeStateReady}, + }}, + policy: &fakePolicy{ + servers: map[string]ServerPolicy{ + "vis": {Found: true, Enabled: true}, + "hidden": {Found: true, Enabled: true}, + }, + quarantine: true, + denied: map[string]bool{}, + }, + scope: NewScope("readonly", []string{"vis"}), + } + return w +} + +// FR-013 scope-silence: at the agent-token tier an out-of-scope id's ENTIRE +// result must be byte-indistinguishable from an ordinary not_found. Comparing +// serialized bytes (not field-by-field asserts) is the point — a future field +// added to only one of the two paths fails this test. +func TestTier_AgentTokenScopeSilenceIsByteIndistinguishable(t *testing.T) { + w := twoServerWorld() + w.tier = TierAgentToken + + results, err := Evaluate(context.Background(), w.ctx(), []ToolRef{ + {ID: "hidden:beta"}, // exists, out of scope + {ID: "vis:beta"}, // in scope, does not exist + }) + require.NoError(t, err) + require.Len(t, results, 2) + + outOfScope, ordinary := results[0], results[1] + assert.Equal(t, ReasonNotFound, outOfScope.Reason, "an out-of-scope id must never reveal server_not_in_scope to a token") + + // Normalize only the echoed id — everything else must match byte for byte. + outOfScope.ID = "" + ordinary.ID = "" + a, err := json.Marshal(outOfScope) + require.NoError(t, err) + b, err := json.Marshal(ordinary) + require.NoError(t, err) + assert.Equal(t, string(b), string(a), "scope-masked not_found must be byte-identical to an ordinary not_found") +} + +// The same silence applies one level up: an UNCONFIGURED server must be +// indistinguishable from an out-of-scope one at the agent-token tier, or a +// token can probe arbitrary names and learn which servers exist at all +// (server_not_configured vs not_found would be the oracle). +func TestTier_AgentTokenUnconfiguredServerIsScopeSilent(t *testing.T) { + w := twoServerWorld() + w.tier = TierAgentToken + + results, err := Evaluate(context.Background(), w.ctx(), []ToolRef{ + {ID: "ghost:beta"}, // server not configured at all + {ID: "hidden:beta"}, // configured, out of scope + }) + require.NoError(t, err) + require.Len(t, results, 2) + + unconfigured, hidden := results[0], results[1] + assert.Equal(t, ReasonNotFound, unconfigured.Reason, + "an unconfigured server must never reveal server_not_configured to a token") + unconfigured.ID = "" + hidden.ID = "" + a, err := json.Marshal(unconfigured) + require.NoError(t, err) + b, err := json.Marshal(hidden) + require.NoError(t, err) + assert.Equal(t, string(b), string(a), + "unconfigured and out-of-scope must be byte-identical at the token tier") +} + +// A suggestion must never cross the scope boundary. +func TestTier_AgentTokenSuggestionsStayInScope(t *testing.T) { + w := twoServerWorld() + w.tier = TierAgentToken + // A near-miss for the hidden tool: "hidden:beto" is one edit from + // "hidden:beta", which the caller must never be told about. + w.policy.servers["hidden"] = ServerPolicy{Found: true, Enabled: true} + + res := evalOne(t, w, ToolRef{ID: "hidden:beto"}) + assert.Equal(t, ReasonNotFound, res.Reason) + assert.Empty(t, res.DidYouMean, "no did_you_mean may name a tool on an out-of-scope server") +} + +// Quarantined servers' names are never suggested (FR-013), even in scope. +func TestSuggestions_ExcludeQuarantinedServers(t *testing.T) { + w := twoServerWorld() + w.scope = nil // unrestricted operator view + w.policy.servers["hidden"] = ServerPolicy{Found: true, Enabled: true, Quarantined: true} + + res := evalOne(t, w, ToolRef{ID: "hidden:betoo"}) + assert.Equal(t, ReasonServerQuarantined, res.Reason, "the quarantined server itself reports quarantine") + + // And a miss elsewhere must not surface the quarantined server's tools. + miss := evalOne(t, w, ToolRef{ID: "vis:beta"}) + assert.Equal(t, ReasonNotFound, miss.Reason) + for _, s := range miss.DidYouMean { + assert.NotContains(t, s, "hidden:", "quarantined server names must never be suggested") + } +} + +// Operator tier gets the full diagnosis, including the profile-session note. +func TestTier_OperatorGetsServerNotInScopeWithProfileDetail(t *testing.T) { + w := twoServerWorld() + w.tier = TierOperator + + res := evalOne(t, w, ToolRef{ID: "hidden:beta"}) + assert.Equal(t, ReasonServerNotInScope, res.Reason) + assert.Equal(t, "configure", res.Action) + assert.False(t, res.Retryable) + assert.Contains(t, res.Detail, "readonly", "the detail names the profile") + assert.Contains(t, res.Detail, "not_found", "the detail explains what a pinned session would see") +} + +// An unnamed scope (e.g. a bare agent-token server list evaluated at the +// operator tier) still explains itself without inventing a profile name. +func TestTier_OperatorScopeWithoutProfileName(t *testing.T) { + w := twoServerWorld() + w.tier = TierOperator + w.scope = NewScope("", []string{"vis"}) + + res := evalOne(t, w, ToolRef{ID: "hidden:beta"}) + assert.Equal(t, ReasonServerNotInScope, res.Reason) + assert.Contains(t, res.Detail, "outside the evaluated scope") +} + +// Hashes are operator-tier disclosure only. +func TestTier_HashDisclosure(t *testing.T) { + operator := evalOne(t, healthyWorld(), ToolRef{ID: id}) + assert.Equal(t, "sha256/v2:abc123", operator.Hash) + + w := healthyWorld() + w.tier = TierAgentToken + agent := evalOne(t, w, ToolRef{ID: id}) + assert.Equal(t, StatusReady, agent.Status) + assert.Empty(t, agent.Hash, "an agent token never receives hashes") +} + +// A pin mismatch must not leak the current hash to a token either. +func TestTier_HashMismatchDetailWithholdsHashesFromTokens(t *testing.T) { + w := healthyWorld() + w.tier = TierAgentToken + res := evalOne(t, w, ToolRef{ID: id, PinHash: "sha256/v2:deadbeef"}) + assert.Equal(t, ReasonHashMismatch, res.Reason) + assert.NotContains(t, res.Detail, "abc123", "the current hash must not appear in an agent-token detail") + + op := evalOne(t, healthyWorld(), ToolRef{ID: id, PinHash: "sha256/v2:deadbeef"}) + assert.Contains(t, op.Detail, "abc123", "the operator tier does get the full diagnosis") +} + +// Profile semantics are "shared index + scope filter": an in-scope id resolves +// exactly as it does unscoped. (The evaluator has no way to reach ForProfile — +// IndexReader has no profile accessor at all.) +func TestProfileScope_UsesSharedIndexWithFilter(t *testing.T) { + w := twoServerWorld() + w.tier = TierOperator + assert.Equal(t, StatusReady, evalOne(t, w, ToolRef{ID: "vis:alpha"}).Status) + + w.scope = nil + assert.Equal(t, StatusReady, evalOne(t, w, ToolRef{ID: "hidden:beta"}).Status, + "without a scope the operator view sees every configured server") +} diff --git a/internal/toolannotations/toolannotations.go b/internal/toolannotations/toolannotations.go new file mode 100644 index 00000000..e377d78d --- /dev/null +++ b/internal/toolannotations/toolannotations.go @@ -0,0 +1,99 @@ +// Package toolannotations is the single source of truth for MCP tool-annotation +// filter semantics (Spec 035 F4, Spec 094 FR-004). +// +// It is a leaf package: it depends only on internal/config for the annotation +// shape and performs no I/O whatsoever. It was extracted from +// internal/server/mcp_annotations.go so that lower-level consumers — notably +// the Spec 098 preflight evaluator — can classify a tool against the same +// filters the retrieve_tools handler applies, without importing internal/server +// (which would be an import cycle) and without re-implementing the semantics +// (which would let the two drift). +// +// The behavior is byte-identical to the pre-extraction implementation; the +// server package now delegates to these functions. +package toolannotations + +import "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + +// Filter parameter names, used as the diagnostics map keys (Spec 094 FR-003) +// and interpolated literally into the suggestion string (FR-006). +const ( + FilterKeyReadOnlyOnly = "read_only_only" + FilterKeyExcludeDestruct = "exclude_destructive" + FilterKeyExcludeOpenWorld = "exclude_open_world" +) + +// Filters is the set of annotation filters a caller activated. The zero value +// means "no filtering", for which every tool passes. +type Filters struct { + ReadOnlyOnly bool + ExcludeDestructive bool + ExcludeOpenWorld bool +} + +// Any reports whether at least one filter is active. +func (f Filters) Any() bool { + return f.ReadOnlyOnly || f.ExcludeDestructive || f.ExcludeOpenWorld +} + +// ExcludeReason decides whether a tool is excluded and, when it is, which +// filter is responsible and why (Spec 094 FR-004). It is the single source of +// truth for the filter semantics — ShouldExclude delegates to it, so the +// diagnostics can never describe a different filter than the one that ran. +// +// Filters are evaluated read-only → destructive → open-world and the FIRST one +// that excludes the tool owns the omission, which keeps per-filter counts +// summable (no double counting). `explicit` distinguishes an omission caused by +// an explicitly unsafe hint (remediation: none, the filter is working) from one +// caused by absent/unset annotations (remediation: fix upstream metadata). +func ExcludeReason(annotations *config.ToolAnnotations, readOnlyOnly, excludeDestructive, excludeOpenWorld bool) (filterKey string, explicit, excluded bool) { + if readOnlyOnly { + // Must have explicit readOnlyHint=true to pass + if annotations == nil || annotations.ReadOnlyHint == nil { + return FilterKeyReadOnlyOnly, false, true + } + if !*annotations.ReadOnlyHint { + return FilterKeyReadOnlyOnly, true, true + } + } + + if excludeDestructive { + // Exclude if destructiveHint is true or nil (default is true per spec). + // However, a tool with readOnlyHint=true is inherently non-destructive, + // so treat destructiveHint as false when readOnlyHint is explicitly true. + isReadOnly := annotations != nil && annotations.ReadOnlyHint != nil && *annotations.ReadOnlyHint + if !isReadOnly { + if annotations == nil || annotations.DestructiveHint == nil { + return FilterKeyExcludeDestruct, false, true + } + if *annotations.DestructiveHint { + return FilterKeyExcludeDestruct, true, true + } + } + } + + if excludeOpenWorld { + // Exclude if openWorldHint is true or nil (default is true per spec) + if annotations == nil || annotations.OpenWorldHint == nil { + return FilterKeyExcludeOpenWorld, false, true + } + if *annotations.OpenWorldHint { + return FilterKeyExcludeOpenWorld, true, true + } + } + + return "", false, false +} + +// ExcludeReasonFor is the Filters-typed form of ExcludeReason, for callers that +// already carry a Filters value. +func ExcludeReasonFor(annotations *config.ToolAnnotations, f Filters) (filterKey string, explicit, excluded bool) { + return ExcludeReason(annotations, f.ReadOnlyOnly, f.ExcludeDestructive, f.ExcludeOpenWorld) +} + +// ShouldExclude returns true if a tool should be excluded based on its +// annotations and the active filters. +func ShouldExclude(annotations *config.ToolAnnotations, readOnlyOnly, excludeDestructive, excludeOpenWorld bool) bool { + _, _, excluded := ExcludeReason(annotations, readOnlyOnly, excludeDestructive, excludeOpenWorld) + return excluded +} diff --git a/internal/toolannotations/toolannotations_test.go b/internal/toolannotations/toolannotations_test.go new file mode 100644 index 00000000..1ce4c4eb --- /dev/null +++ b/internal/toolannotations/toolannotations_test.go @@ -0,0 +1,213 @@ +package toolannotations + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +func boolPtr(b bool) *bool { return &b } + +// --- Spec 094 T001 parity oracle, moved here with the classifier (Spec 098 T005) --- +// +// ShouldExclude delegates to ExcludeReason, so comparing the two live functions +// would be circular. legacyShouldExcludeOracle is the verbatim body of +// shouldExclude as it stood before the spec-094 attribution refactor +// (internal/server/mcp_annotations.go:153-181 at 8ed4e4689) — an independent +// oracle that catches semantic drift the delegation cannot. +// +// Filter semantics are FROZEN by the spec: this file must never be "fixed" to +// match a changed implementation. +func legacyShouldExcludeOracle(annotations *config.ToolAnnotations, readOnlyOnly, excludeDestructive, excludeOpenWorld bool) bool { + if readOnlyOnly { + if annotations == nil || annotations.ReadOnlyHint == nil || !*annotations.ReadOnlyHint { + return true + } + } + + if excludeDestructive { + isReadOnly := annotations != nil && annotations.ReadOnlyHint != nil && *annotations.ReadOnlyHint + if !isReadOnly { + if annotations == nil || annotations.DestructiveHint == nil || *annotations.DestructiveHint { + return true + } + } + } + + if excludeOpenWorld { + if annotations == nil || annotations.OpenWorldHint == nil || *annotations.OpenWorldHint { + return true + } + } + + return false +} + +// expectedAttribution derives the first-failure filter key and reason class +// straight from spec 094 FR-004 / data-model.md, independently of both the +// implementation and the oracle above. +func expectedAttribution(a *config.ToolAnnotations, readOnlyOnly, excludeDestructive, excludeOpenWorld bool) (filterKey string, explicit, excluded bool) { + hint := func(p *bool) (set, val bool) { + if a == nil || p == nil { + return false, false + } + return true, *p + } + var roPtr, destPtr, owPtr *bool + if a != nil { + roPtr, destPtr, owPtr = a.ReadOnlyHint, a.DestructiveHint, a.OpenWorldHint + } + roSet, roVal := hint(roPtr) + destSet, destVal := hint(destPtr) + owSet, owVal := hint(owPtr) + + if readOnlyOnly { + switch { + case !roSet: + return "read_only_only", false, true + case !roVal: + return "read_only_only", true, true + } + } + + explicitlyReadOnly := roSet && roVal + if excludeDestructive && !explicitlyReadOnly { + switch { + case !destSet: + return "exclude_destructive", false, true + case destVal: + return "exclude_destructive", true, true + } + } + + if excludeOpenWorld { + switch { + case !owSet: + return "exclude_open_world", false, true + case owVal: + return "exclude_open_world", true, true + } + } + + return "", false, false +} + +var hintStates = []struct { + label string + value *bool +}{ + {"unset", nil}, + {"true", boolPtr(true)}, + {"false", boolPtr(false)}, +} + +// TestExcludeReason_ParityWithFrozenOracle exhausts the full domain: 27 +// non-nil hint combinations + the nil-annotations state, times the 8 filter +// combinations = 224 cases. Every case asserts all three outputs, plus the +// Filters-typed wrapper added for the preflight evaluator. +func TestExcludeReason_ParityWithFrozenOracle(t *testing.T) { + type annState struct { + label string + annotations *config.ToolAnnotations + } + states := []annState{{"nil-annotations", nil}} + for _, ro := range hintStates { + for _, dest := range hintStates { + for _, ow := range hintStates { + states = append(states, annState{ + label: "readOnly=" + ro.label + "/destructive=" + dest.label + "/openWorld=" + ow.label, + annotations: &config.ToolAnnotations{ + ReadOnlyHint: ro.value, + DestructiveHint: dest.value, + OpenWorldHint: ow.value, + }, + }) + } + } + } + require.Len(t, states, 28, "28 annotation states: 3^3 hint combos + nil annotations") + + filterCombos := []Filters{ + {false, false, false}, + {true, false, false}, + {false, true, false}, + {false, false, true}, + {true, true, false}, + {true, false, true}, + {false, true, true}, + {true, true, true}, + } + + cases := 0 + for _, st := range states { + for _, fc := range filterCombos { + cases++ + name := fmt.Sprintf("%s|ro=%t,xd=%t,xow=%t", st.label, fc.ReadOnlyOnly, fc.ExcludeDestructive, fc.ExcludeOpenWorld) + t.Run(name, func(t *testing.T) { + gotKey, gotExplicit, gotExcluded := ExcludeReason(st.annotations, fc.ReadOnlyOnly, fc.ExcludeDestructive, fc.ExcludeOpenWorld) + + wantExcluded := legacyShouldExcludeOracle(st.annotations, fc.ReadOnlyOnly, fc.ExcludeDestructive, fc.ExcludeOpenWorld) + assert.Equal(t, wantExcluded, gotExcluded, "excluded must match the frozen pre-refactor oracle") + + assert.Equal(t, wantExcluded, ShouldExclude(st.annotations, fc.ReadOnlyOnly, fc.ExcludeDestructive, fc.ExcludeOpenWorld), + "ShouldExclude must stay semantically frozen") + + // The Filters-typed wrapper must be a pure restatement. + wKey, wExplicit, wExcluded := ExcludeReasonFor(st.annotations, fc) + assert.Equal(t, gotKey, wKey) + assert.Equal(t, gotExplicit, wExplicit) + assert.Equal(t, gotExcluded, wExcluded) + + wantKey, wantExplicit, wantExcludedAttr := expectedAttribution(st.annotations, fc.ReadOnlyOnly, fc.ExcludeDestructive, fc.ExcludeOpenWorld) + require.Equal(t, wantExcludedAttr, wantExcluded, "test oracles disagree — fix the test, not the code") + assert.Equal(t, wantKey, gotKey, "first-failure filter key (read-only -> destructive -> open-world)") + assert.Equal(t, wantExplicit, gotExplicit, "reason class (missing vs explicit)") + + if !gotExcluded { + assert.Empty(t, gotKey, "a kept tool has no responsible filter") + assert.False(t, gotExplicit, "a kept tool has no reason class") + } + }) + } + } + assert.Equal(t, 224, cases, "exhaustive domain is 28 annotation states x 8 filter combos") +} + +// The read-only shortcut is the subtlest frozen semantic: an explicitly +// read-only tool passes exclude_destructive even with destructiveHint=true, +// so it can only ever be attributed to read_only_only or exclude_open_world. +func TestExcludeReason_ReadOnlyShortcutAttribution(t *testing.T) { + readOnlyDestructive := &config.ToolAnnotations{ + ReadOnlyHint: boolPtr(true), + DestructiveHint: boolPtr(true), + } + + key, explicit, excluded := ExcludeReason(readOnlyDestructive, false, true, false) + assert.False(t, excluded, "explicit readOnlyHint=true passes exclude_destructive (frozen shortcut)") + assert.Empty(t, key) + assert.False(t, explicit) + + key, explicit, excluded = ExcludeReason(readOnlyDestructive, false, true, true) + assert.True(t, excluded) + assert.Equal(t, FilterKeyExcludeOpenWorld, key) + assert.False(t, explicit, "openWorldHint unset is a missing-annotation omission") +} + +// TestFilterKeys_FrozenWireValues pins the three keys: they are diagnostics map +// keys and preflight `detail` text, i.e. a wire contract. +func TestFilterKeys_FrozenWireValues(t *testing.T) { + assert.Equal(t, "read_only_only", FilterKeyReadOnlyOnly) + assert.Equal(t, "exclude_destructive", FilterKeyExcludeDestruct) + assert.Equal(t, "exclude_open_world", FilterKeyExcludeOpenWorld) +} + +func TestFilters_Any(t *testing.T) { + assert.False(t, Filters{}.Any()) + assert.True(t, Filters{ReadOnlyOnly: true}.Any()) + assert.True(t, Filters{ExcludeDestructive: true}.Any()) + assert.True(t, Filters{ExcludeOpenWorld: true}.Any()) +} From 5a15371f1a3ca53ca9ecdc253ccc26d8a96cf214 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 15 Aug 2026 20:47:00 +0300 Subject: [PATCH 4/9] feat(preflight): server glue, four-path dispatch consolidation, synchronous activity seam (098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #969 RunPreflight glue over index/approvals/stateview/config (zero upstream I/O; ForProfile unreachable; annotations enriched from the stateview snapshot). tool_gate.go: one shared classification consumed by call_tool variants, direct mode, code_execution and stored scripts — scripts previously enforced NO per-tool policy (FR-002). Synchronous durable RecordPreflight bypassing the bounded event channel. Sabotage E2E matrix (22 cells + covers-every-reason), dispatch-parity tests, merge-base tools/list goldens (byte-identical, FR-015). --- internal/runtime/activity_preflight.go | 162 +++ internal/runtime/activity_preflight_test.go | 136 +++ internal/server/mcp.go | 149 +-- internal/server/mcp_annotations.go | 70 +- internal/server/mcp_code_execution.go | 48 + internal/server/mcp_direct_callability.go | 45 +- internal/server/mcp_visibility.go | 25 +- .../server/preflight_dispatch_parity_test.go | 292 ++++++ internal/server/preflight_e2e_test.go | 930 ++++++++++++++++++ internal/server/preflight_glue.go | 400 ++++++++ internal/server/preflight_glue_test.go | 338 +++++++ .../server/preflight_index_shadowing_test.go | 120 +++ internal/server/preflight_matrix_test.go | 192 ++++ internal/server/server.go | 9 +- .../testdata/preflight_fixture_server.js | 131 +++ .../testdata/preflight_sabotage_matrix.json | 290 ++++++ .../code_execution_mode.json | 293 ++++++ .../toolslist_goldens/default_server.json | 469 +++++++++ .../retrieve_tools_mode.json | 490 +++++++++ internal/server/tool_gate.go | 142 +++ internal/server/toolslist_snapshot_test.go | 185 ++++ internal/storage/activity_models.go | 32 + .../storage/activity_models_preflight_test.go | 105 ++ test/e2e-config.json | 11 +- 24 files changed, 4897 insertions(+), 167 deletions(-) create mode 100644 internal/runtime/activity_preflight.go create mode 100644 internal/runtime/activity_preflight_test.go create mode 100644 internal/server/preflight_dispatch_parity_test.go create mode 100644 internal/server/preflight_e2e_test.go create mode 100644 internal/server/preflight_glue.go create mode 100644 internal/server/preflight_glue_test.go create mode 100644 internal/server/preflight_index_shadowing_test.go create mode 100644 internal/server/preflight_matrix_test.go create mode 100644 internal/server/testdata/preflight_fixture_server.js create mode 100644 internal/server/testdata/preflight_sabotage_matrix.json create mode 100644 internal/server/testdata/toolslist_goldens/code_execution_mode.json create mode 100644 internal/server/testdata/toolslist_goldens/default_server.json create mode 100644 internal/server/testdata/toolslist_goldens/retrieve_tools_mode.json create mode 100644 internal/server/tool_gate.go create mode 100644 internal/server/toolslist_snapshot_test.go create mode 100644 internal/storage/activity_models_preflight_test.go diff --git a/internal/runtime/activity_preflight.go b/internal/runtime/activity_preflight.go new file mode 100644 index 00000000..7c676b73 --- /dev/null +++ b/internal/runtime/activity_preflight.go @@ -0,0 +1,162 @@ +package runtime + +import ( + "errors" + "fmt" + "time" + + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" +) + +// Errors a preflight activity write can fail with. They are sentinels because +// the served preflight surface must answer 503 when the record could not be +// persisted (spec 098 FR-008/FR-014: a 200 without its activity record would +// break the transparency guarantee), and it should be able to tell "the proxy is +// shutting down" from "the write failed". +var ( + // ErrActivityUnavailable means there is no activity store to write to. + ErrActivityUnavailable = errors.New("activity service unavailable") + // ErrActivityShuttingDown means the service already closed its write + // barrier; the DB may be closing, so nothing new may be written. + ErrActivityShuttingDown = errors.New("activity service is shutting down") +) + +// PreflightToolOutcome is one per-tool line of a preflight activity record. +// Reason is empty for a ready tool. +type PreflightToolOutcome struct { + ID string + Status string + Reason string +} + +// PreflightActivity is one executed preflight, as the served surface hands it to +// the activity log. Everything here is either an enum value, a count or a tool +// ID — never a description, argument or hash (FR-014: local activity log only, +// nothing that could leak to a telemetry surface). +type PreflightActivity struct { + // RequestID correlates the record with the response's X-Request-Id and with + // every tool call the same workflow makes afterwards. + RequestID string + SessionID string + Source storage.ActivitySource + // Verdict is the set-level verdict (preflight.Verdict*). + Verdict string + // Status overrides the derived activity status. Leave empty to derive it + // from Verdict via PreflightActivityStatus. + Status string + // Timestamp defaults to time.Now() when zero. + Timestamp time.Time + Tools []PreflightToolOutcome + + // Multi-user identity (server edition); empty in the personal edition. + UserID string + UserEmail string +} + +// PreflightActivityStatus maps a set verdict onto the CLOSED activity status +// vocabulary (see storage.ValidActivityStatuses): an all-ready preflight is a +// success, anything else is a policy/state block the operator has to act on. No +// new status value is introduced — spec 093 FR-012 makes that a cross-surface +// change, and preflight does not need one. +func PreflightActivityStatus(verdict string) string { + if verdict == preflight.VerdictReady { + return storage.ActivityStatusSuccess + } + return storage.ActivityStatusBlocked +} + +// RecordPreflight persists one preflight run SYNCHRONOUSLY and returns the write +// error (spec 098 FR-014). +// +// It deliberately bypasses the bounded async event channel every other activity +// path uses: that channel drops events for a subscriber that falls behind, and +// FR-014 requires the record to be durable BEFORE the caller is answered. This +// is the same trade RecordToolCallRejected makes — one BBolt write on the +// caller's goroutine, joined to the shutdown barrier via enterWrite so it can +// never straddle a DB close (Spec 080 FR-010). +func (s *ActivityService) RecordPreflight(rec PreflightActivity) error { + if s == nil || s.storage == nil { + return ErrActivityUnavailable + } + if !s.enterWrite() { + return ErrActivityShuttingDown + } + defer s.workersWG.Done() + + timestamp := rec.Timestamp + if timestamp.IsZero() { + timestamp = time.Now() + } + status := rec.Status + if status == "" { + status = PreflightActivityStatus(rec.Verdict) + } + source := rec.Source + if source == "" { + source = storage.ActivitySourceAPI + } + + record := &storage.ActivityRecord{ + Type: storage.ActivityTypePreflight, + Source: source, + Status: status, + Timestamp: timestamp, + SessionID: rec.SessionID, + RequestID: rec.RequestID, + WorkSessionID: s.resolveWorkSession(rec.SessionID), + UserID: rec.UserID, + UserEmail: rec.UserEmail, + Metadata: preflightMetadata(rec), + } + + if err := s.storage.SaveActivity(record); err != nil { + if s.logger != nil { + s.logger.Error("Failed to save preflight activity record", + zap.Error(err), + zap.String("request_id", rec.RequestID), + zap.String("verdict", rec.Verdict)) + } + return fmt.Errorf("save preflight activity record: %w", err) + } + return nil +} + +// RecordPreflight is the Runtime-level passthrough onto the activity service's +// synchronous preflight write, so a served surface holding only the runtime can +// satisfy FR-014 without reaching for the service directly. +func (r *Runtime) RecordPreflight(rec PreflightActivity) error { + if r == nil || r.activityService == nil { + return ErrActivityUnavailable + } + return r.activityService.RecordPreflight(rec) +} + +// preflightMetadata builds the documented payload: +// {verdict, ids_count, reasons{code:count}, per_tool[{id,status,reason?}]}. +func preflightMetadata(rec PreflightActivity) map[string]interface{} { + reasons := make(map[string]int) + perTool := make([]map[string]interface{}, 0, len(rec.Tools)) + for _, tool := range rec.Tools { + entry := map[string]interface{}{ + storage.PreflightPerToolKeyID: tool.ID, + storage.PreflightPerToolKeyStatus: tool.Status, + } + // A ready tool carries no reason at all — the field is omitted rather + // than emitted empty, mirroring the wire DTO. + if tool.Reason != "" { + entry[storage.PreflightPerToolKeyReason] = tool.Reason + reasons[tool.Reason]++ + } + perTool = append(perTool, entry) + } + + return map[string]interface{}{ + storage.MetadataKeyPreflightVerdict: rec.Verdict, + storage.MetadataKeyPreflightIDsCount: len(rec.Tools), + storage.MetadataKeyPreflightReasons: reasons, + storage.MetadataKeyPreflightPerTool: perTool, + } +} diff --git a/internal/runtime/activity_preflight_test.go b/internal/runtime/activity_preflight_test.go new file mode 100644 index 00000000..f2ae8e3e --- /dev/null +++ b/internal/runtime/activity_preflight_test.go @@ -0,0 +1,136 @@ +package runtime + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" +) + +func newPreflightActivityService(t *testing.T) (*ActivityService, *storage.Manager) { + t.Helper() + mgr, cleanup := setupTestStorage(t) + t.Cleanup(cleanup) + return NewActivityService(mgr, zap.NewNop()), mgr +} + +// RecordPreflight must be DURABLE by the time it returns — the served surface +// answers 200 on the strength of that (FR-014). The service's async event loop +// is never started here on purpose: if the write went through the bounded event +// channel, this test would find no record at all. +func TestRecordPreflightWritesSynchronously(t *testing.T) { + svc, mgr := newPreflightActivityService(t) + + err := svc.RecordPreflight(PreflightActivity{ + RequestID: "req-sync", + Source: storage.ActivitySourceCLI, + Verdict: preflight.VerdictBlocked, + Timestamp: time.Now(), + Tools: []PreflightToolOutcome{ + {ID: "gh:create_issue", Status: preflight.StatusReady}, + {ID: "slack:post", Status: preflight.StatusUnavailable, Reason: preflight.ReasonServerQuarantined}, + {ID: "slack:read", Status: preflight.StatusUnavailable, Reason: preflight.ReasonServerQuarantined}, + }, + }) + require.NoError(t, err) + + filter := storage.DefaultActivityFilter() + filter.Types = []string{string(storage.ActivityTypePreflight)} + records, total, err := mgr.ListActivities(filter) + require.NoError(t, err) + require.Equal(t, 1, total) + require.Len(t, records, 1) + + record := records[0] + assert.Equal(t, storage.ActivityTypePreflight, record.Type) + assert.Equal(t, "req-sync", record.RequestID) + assert.Equal(t, storage.ActivitySourceCLI, record.Source) + // Non-ready verdict maps onto the existing closed status vocabulary. + assert.Equal(t, storage.ActivityStatusBlocked, record.Status) + assert.Contains(t, storage.ValidActivityStatuses, record.Status) + + assert.Equal(t, preflight.VerdictBlocked, record.Metadata[storage.MetadataKeyPreflightVerdict]) + assert.InDelta(t, 3, record.Metadata[storage.MetadataKeyPreflightIDsCount], 0.0001) + + reasons, ok := record.Metadata[storage.MetadataKeyPreflightReasons].(map[string]interface{}) + require.True(t, ok, "reasons must survive the BBolt JSON round trip") + assert.InDelta(t, 2, reasons[preflight.ReasonServerQuarantined], 0.0001) + + perTool, ok := record.Metadata[storage.MetadataKeyPreflightPerTool].([]interface{}) + require.True(t, ok) + require.Len(t, perTool, 3) + first, ok := perTool[0].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "gh:create_issue", first[storage.PreflightPerToolKeyID]) + assert.Equal(t, preflight.StatusReady, first[storage.PreflightPerToolKeyStatus]) + _, hasReason := first[storage.PreflightPerToolKeyReason] + assert.False(t, hasReason, "a ready tool carries no reason") +} + +func TestRecordPreflightReadyVerdictIsSuccess(t *testing.T) { + svc, mgr := newPreflightActivityService(t) + + require.NoError(t, svc.RecordPreflight(PreflightActivity{ + RequestID: "req-ready", + Verdict: preflight.VerdictReady, + Tools: []PreflightToolOutcome{{ID: "gh:create_issue", Status: preflight.StatusReady}}, + })) + + filter := storage.DefaultActivityFilter() + filter.RequestID = "req-ready" + records, _, err := mgr.ListActivities(filter) + require.NoError(t, err) + require.Len(t, records, 1) + assert.Equal(t, storage.ActivityStatusSuccess, records[0].Status) + // Source defaults to the REST surface when the caller does not set one. + assert.Equal(t, storage.ActivitySourceAPI, records[0].Source) +} + +// A failed write must PROPAGATE: the handler turns it into a 503 rather than +// answering 200 with no record (FR-008/FR-014). +func TestRecordPreflightPropagatesWriteFailure(t *testing.T) { + svc, mgr := newPreflightActivityService(t) + require.NoError(t, mgr.Close()) + + err := svc.RecordPreflight(PreflightActivity{ + RequestID: "req-fail", + Verdict: preflight.VerdictReady, + Tools: []PreflightToolOutcome{{ID: "gh:create_issue", Status: preflight.StatusReady}}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "save preflight activity record") +} + +func TestRecordPreflightWithoutStorageIsUnavailable(t *testing.T) { + var nilService *ActivityService + assert.ErrorIs(t, nilService.RecordPreflight(PreflightActivity{}), ErrActivityUnavailable) + + svc := NewActivityService(nil, zap.NewNop()) + assert.ErrorIs(t, svc.RecordPreflight(PreflightActivity{}), ErrActivityUnavailable) +} + +// Once the service has closed its write barrier the DB may be closing, so a late +// preflight must be refused rather than risk a write racing the close. +func TestRecordPreflightAfterStopIsRefused(t *testing.T) { + svc, _ := newPreflightActivityService(t) + svc.Stop() + + err := svc.RecordPreflight(PreflightActivity{RequestID: "req-late", Verdict: preflight.VerdictReady}) + assert.ErrorIs(t, err, ErrActivityShuttingDown) +} + +func TestPreflightActivityStatusMapping(t *testing.T) { + assert.Equal(t, storage.ActivityStatusSuccess, PreflightActivityStatus(preflight.VerdictReady)) + for _, verdict := range []string{ + preflight.VerdictDegradedRetryable, + preflight.VerdictBlocked, + preflight.VerdictUnknownIDs, + } { + assert.Equal(t, storage.ActivityStatusBlocked, PreflightActivityStatus(verdict), verdict) + } +} diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 29beff01..922e1eea 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -26,6 +26,7 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/oauth" "github.com/smart-mcp-proxy/mcpproxy-go/internal/observability" "github.com/smart-mcp-proxy/mcpproxy-go/internal/outputvalidation" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" "github.com/smart-mcp-proxy/mcpproxy-go/internal/registries" "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext" "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" @@ -2107,9 +2108,14 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp. // (e.g. FastMCP's Pydantic validate_call). See #322. activityArgs := injectAuthMetadata(ctx, args) - // Check if server is quarantined before calling tool - serverConfig, err := p.storage.GetUpstreamServer(serverName) - if err == nil && serverConfig.Quarantined { + // Spec 098 FR-002: one evaluation of the shared policy gates — server + // quarantine, tool-level quarantine (Spec 032) and callability — through the + // same classifier the preflight evaluator uses, so a tool dispatch refuses + // can never preflight as `ready`. The response SELECTION below keeps the + // long-standing dispatch order (quarantine → approval lock → generic block). + gate := p.evaluateToolGate(serverName, actualToolName) + + if gate.serverQuarantined() { p.logger.Debug("handleCallToolVariant: server is quarantined", zap.String("server_name", serverName)) @@ -2120,36 +2126,29 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp. return p.handleQuarantinedToolCall(ctx, serverName, actualToolName, activityArgs), nil } - // Check tool-level quarantine (Spec 032) - only if server is not quarantined - if p.config.IsQuarantineEnabled() { - if serverConfig != nil && !serverConfig.IsQuarantineSkipped() { - if approval, approvalErr := p.storage.GetToolApproval(serverName, actualToolName); approvalErr == nil { - if approval.Status == storage.ToolApprovalStatusPending { - p.logger.Debug("handleCallToolVariant: tool is pending approval (quarantined)", - zap.String("server_name", serverName), - zap.String("tool_name", actualToolName)) + switch gate.lockStatus { + case storage.ToolApprovalStatusPending: + p.logger.Debug("handleCallToolVariant: tool is pending approval (quarantined)", + zap.String("server_name", serverName), + zap.String("tool_name", actualToolName)) - p.emitActivityPolicyDecision(serverName, actualToolName, getSessionID(), requestID, "blocked", - "Tool is pending approval (new unapproved tool)") + p.emitActivityPolicyDecision(serverName, actualToolName, getSessionID(), requestID, "blocked", + "Tool is pending approval (new unapproved tool)") - return toolPendingApprovalResult(serverName, actualToolName, approval), nil - } - if approval.Status == storage.ToolApprovalStatusChanged { - p.logger.Debug("handleCallToolVariant: tool description changed (quarantined)", - zap.String("server_name", serverName), - zap.String("tool_name", actualToolName)) + return toolPendingApprovalResult(serverName, actualToolName, gate.approval), nil + case storage.ToolApprovalStatusChanged: + p.logger.Debug("handleCallToolVariant: tool description changed (quarantined)", + zap.String("server_name", serverName), + zap.String("tool_name", actualToolName)) - p.emitActivityPolicyDecision(serverName, actualToolName, getSessionID(), requestID, "blocked", - "Tool description/schema changed since last approval") + p.emitActivityPolicyDecision(serverName, actualToolName, getSessionID(), requestID, "blocked", + "Tool description/schema changed since last approval") - return toolChangedApprovalResult(serverName, actualToolName, approval), nil - } - } - } + return toolChangedApprovalResult(serverName, actualToolName, gate.approval), nil } - if !p.isToolCallable(serverName, actualToolName) { - errMsg := p.blockedToolMessage(serverName, actualToolName) + if !gate.callable() { + errMsg := gate.blockedMessage() p.emitActivityPolicyDecision(serverName, actualToolName, sessionID, requestID, "blocked", errMsg) return mcp.NewToolResultError(errMsg), nil } @@ -2268,10 +2267,11 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp. } } - // Derive server ID safely (serverConfig may be nil if storage lookup failed) + // Derive server ID safely (the gate's server record is nil when the storage + // lookup failed) var serverID string - if serverConfig != nil { - serverID = storage.GenerateServerID(serverConfig) + if gate.serverConfig != nil { + serverID = storage.GenerateServerID(gate.serverConfig) } // Record tool call for history (even if error) @@ -2626,9 +2626,12 @@ func (p *MCPProxyServer) handleCallTool(ctx context.Context, request mcp.CallToo // Spec 028: Inject auth identity into activity metadata (separate copy for logging only) activityArgs := injectAuthMetadata(ctx, args) - // Check if server is quarantined before calling tool - serverConfig, err := p.storage.GetUpstreamServer(serverName) - if err == nil && serverConfig.Quarantined { + // Shared policy gates (Spec 098 FR-002), same primitive as every other + // dispatch path. + gate := p.evaluateToolGate(serverName, actualToolName) + serverConfig := gate.serverConfig + + if gate.serverQuarantined() { p.logger.Debug("handleCallTool: server is quarantined", zap.String("server_name", serverName)) @@ -2642,8 +2645,19 @@ func (p *MCPProxyServer) handleCallTool(ctx context.Context, request mcp.CallToo p.logger.Debug("handleCallTool: checking connection status", zap.String("server_name", serverName)) - if !p.isToolCallable(serverName, actualToolName) { - errMsg := p.blockedToolMessage(serverName, actualToolName) + switch gate.lockStatus { + case storage.ToolApprovalStatusPending: + p.emitActivityPolicyDecision(serverName, actualToolName, sessionID, requestID, "blocked", + "Tool is pending approval (new unapproved tool)") + return toolPendingApprovalResult(serverName, actualToolName, gate.approval), nil + case storage.ToolApprovalStatusChanged: + p.emitActivityPolicyDecision(serverName, actualToolName, sessionID, requestID, "blocked", + "Tool description/schema changed since last approval") + return toolChangedApprovalResult(serverName, actualToolName, gate.approval), nil + } + + if !gate.callable() { + errMsg := gate.blockedMessage() p.emitActivityPolicyDecision(serverName, actualToolName, sessionID, requestID, "blocked", errMsg) return mcp.NewToolResultError(errMsg), nil } @@ -5762,46 +5776,41 @@ func (p *MCPProxyServer) serverToolCounts(serverName string, toolNames []string) } // classifyServerToolStatus returns "" when the tool is callable, otherwise the -// disable reason. Mirrors classifyDisabledTool's precedence so the two never -// drift. The config-denied leg prefers the live runtime signal (the same -// authority isToolCallable/blockedToolMessage use — config-file disabled_tools -// only lives in the live config, not always in the storage copy); it falls -// back to the storage ServerConfig when no runtime is wired (unit tests). +// disable reason. It delegates to the SHARED gate primitive (Spec 098 FR-002 / +// research D2), so the discovery-facing status can no longer disagree with what +// dispatch would actually do. Two divergences of the pre-098 classifier are +// resolved in dispatch's favor, because dispatch is ground truth: +// +// - the pending/changed gate now honors the quarantine flags (global switch + +// per-server trust_mode auto). The old version checked the approval status +// unconditionally and therefore reported false "pending_approval" for +// auto-approving servers whose tools dispatch happily calls; +// - a quarantined SERVER now reports server_quarantined instead of "callable" +// — every dispatch path refuses those tools. +// +// A genuine approval-read failure keeps the historical behavior here (report +// callable rather than invent a lock): this surface describes state, it does not +// gate anything, and the gates themselves still fail closed. func (p *MCPProxyServer) classifyServerToolStatus(serverName, toolName string) contracts.DisabledToolStatus { - if strings.Contains(toolName, ":") { - if parts := strings.SplitN(toolName, ":", 2); len(parts) == 2 { - if serverName == "" { - serverName = parts[0] - } - toolName = parts[1] - } - } - sc, err := p.storage.GetUpstreamServer(serverName) - if err != nil || sc == nil { + gate := p.evaluateToolGate(serverName, toolName) + switch gate.class { + case preflight.ToolClassServerNotConfigured: return contracts.DisabledStatusUnknown - } - if !sc.Enabled { + case preflight.ToolClassServerQuarantined: + return contracts.DisabledStatusServerQuarantined + case preflight.ToolClassServerDisabled: return contracts.DisabledStatusServerDisabled - } - configDenied := false - if p.mainServer != nil && p.mainServer.runtime != nil { - configDenied = p.mainServer.runtime.IsToolConfigDenied(serverName, toolName) - } else { - configDenied = !sc.IsToolAllowedByConfig(toolName) - } - if configDenied { + case preflight.ToolClassDeniedByConfig: return contracts.DisabledStatusByConfig + case preflight.ToolClassBlockedByUser: + return contracts.DisabledStatusByUser + case preflight.ToolClassPendingApproval, preflight.ToolClassChanged: + return contracts.DisabledStatusPendingApproval + case preflight.ToolClassReady: + return "" // callable + default: + return contracts.DisabledStatusUnknown } - if approval, aerr := p.storage.GetToolApproval(serverName, toolName); aerr == nil && approval != nil { - if approval.Disabled { - return contracts.DisabledStatusByUser - } - if approval.Status == storage.ToolApprovalStatusPending || - approval.Status == storage.ToolApprovalStatusChanged { - return contracts.DisabledStatusPendingApproval - } - } - return "" // callable } // serverToolNames returns the best-available list of a server's tool names: diff --git a/internal/server/mcp_annotations.go b/internal/server/mcp_annotations.go index c42ddff8..dd4a2cc4 100644 --- a/internal/server/mcp_annotations.go +++ b/internal/server/mcp_annotations.go @@ -6,6 +6,7 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime/stateview" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/toolannotations" ) // SessionRisk holds the result of analyzing all connected servers' tool annotations @@ -247,63 +248,20 @@ func filterByAnnotationsWithDiagnostics(tools []annotatedSearchResult, readOnlyO // shouldExclude returns true if a tool should be excluded based on its annotations and active filters. func shouldExclude(annotations *config.ToolAnnotations, readOnlyOnly, excludeDestructive, excludeOpenWorld bool) bool { - _, _, excluded := excludeReason(annotations, readOnlyOnly, excludeDestructive, excludeOpenWorld) - return excluded + return toolannotations.ShouldExclude(annotations, readOnlyOnly, excludeDestructive, excludeOpenWorld) } -// Filter parameter names, used as the diagnostics map keys (Spec 094 FR-003) -// and interpolated literally into the suggestion string (FR-006). -const ( - filterKeyReadOnlyOnly = "read_only_only" - filterKeyExcludeDestruct = "exclude_destructive" - filterKeyExcludeOpenWorld = "exclude_open_world" -) - -// excludeReason decides whether a tool is excluded and, when it is, which -// filter is responsible and why (Spec 094 FR-004). It is the single source of -// truth for the filter semantics — shouldExclude delegates to it, so the -// diagnostics can never describe a different filter than the one that ran. -// -// Filters are evaluated read-only → destructive → open-world and the FIRST one -// that excludes the tool owns the omission, which keeps per-filter counts -// summable (no double counting). `explicit` distinguishes an omission caused by -// an explicitly unsafe hint (remediation: none, the filter is working) from one -// caused by absent/unset annotations (remediation: fix upstream metadata). +// The filter parameter names — used as the diagnostics map keys (Spec 094 +// FR-003) and interpolated literally into the suggestion string (FR-006) — now +// live in internal/toolannotations (FilterKeyReadOnlyOnly and friends) and +// arrive here as the filterKey returned by excludeReason, so the server-side +// wording cannot diverge from the classifier's attribution keys. + +// excludeReason delegates to the shared classifier in internal/toolannotations +// (Spec 098 T005). The semantics — first-failing filter owns the omission, +// evaluated read-only → destructive → open-world, `explicit` distinguishing an +// unsafe hint from a missing one — are frozen by Spec 094 FR-004 and now live in +// one place so the preflight evaluator and retrieve_tools cannot drift apart. func excludeReason(annotations *config.ToolAnnotations, readOnlyOnly, excludeDestructive, excludeOpenWorld bool) (filterKey string, explicit, excluded bool) { - if readOnlyOnly { - // Must have explicit readOnlyHint=true to pass - if annotations == nil || annotations.ReadOnlyHint == nil { - return filterKeyReadOnlyOnly, false, true - } - if !*annotations.ReadOnlyHint { - return filterKeyReadOnlyOnly, true, true - } - } - - if excludeDestructive { - // Exclude if destructiveHint is true or nil (default is true per spec). - // However, a tool with readOnlyHint=true is inherently non-destructive, - // so treat destructiveHint as false when readOnlyHint is explicitly true. - isReadOnly := annotations != nil && annotations.ReadOnlyHint != nil && *annotations.ReadOnlyHint - if !isReadOnly { - if annotations == nil || annotations.DestructiveHint == nil { - return filterKeyExcludeDestruct, false, true - } - if *annotations.DestructiveHint { - return filterKeyExcludeDestruct, true, true - } - } - } - - if excludeOpenWorld { - // Exclude if openWorldHint is true or nil (default is true per spec) - if annotations == nil || annotations.OpenWorldHint == nil { - return filterKeyExcludeOpenWorld, false, true - } - if *annotations.OpenWorldHint { - return filterKeyExcludeOpenWorld, true, true - } - } - - return "", false, false + return toolannotations.ExcludeReason(annotations, readOnlyOnly, excludeDestructive, excludeOpenWorld) } diff --git a/internal/server/mcp_code_execution.go b/internal/server/mcp_code_execution.go index 03c8522c..b7255994 100644 --- a/internal/server/mcp_code_execution.go +++ b/internal/server/mcp_code_execution.go @@ -177,6 +177,7 @@ func (p *MCPProxyServer) handleCodeExecution(ctx context.Context, request mcp.Ca clientName: clientName, clientVersion: clientVersion, mainServer: p.mainServer, + proxy: p, } // Log pool metrics before acquisition @@ -697,6 +698,10 @@ type upstreamToolCaller struct { clientName string // MCP client name clientVersion string // MCP client version mainServer *Server // Reference to main server for tokenizer access + // proxy is the policy authority for the shared dispatch gates (Spec 098 + // FR-002). nil in unit tests that drive the caller directly, in which case + // the gate is skipped exactly as it was before the consolidation. + proxy *MCPProxyServer } // CallTool implements jsruntime.ToolCaller interface @@ -716,6 +721,23 @@ func (u *upstreamToolCaller) CallTool(ctx context.Context, serverName, toolName zap.String("tool", toolName), ) + // Spec 098 FR-002: the sandbox is a dispatch path like any other, so it + // consumes the same shared gate primitive as call_tool_* and direct mode — + // a script must not reach a quarantined, disabled, config-denied or + // approval-locked tool that every other surface refuses. This covers both + // script surfaces: ad-hoc code_execution and stored scripts (spec 097). + // + // It is deliberately FAIL-OPEN for an unknown server (no stored record): + // dispatch has always been permissive about existence (FR-002 makes that + // guarantee one-way), and tightening it here would break in-process fixtures + // that register an upstream without a config record. + if refusal := u.policyRefusal(serverName, toolName); refusal != nil { + duration := time.Since(startTime) + u.recordToolCall(serverName, toolName, startTime, duration, false, refusal.Error()) + u.storeToolCallInHistory(serverName, toolName, args, nil, refusal, startTime, duration) + return nil, refusal + } + // Get the managed client for the server client, exists := u.upstreamManager.GetClient(serverName) if !exists { @@ -752,6 +774,32 @@ func (u *upstreamToolCaller) CallTool(ctx context.Context, serverName, toolName return result, nil } +// policyRefusal evaluates the shared per-tool policy gates for a sandboxed call +// and returns the refusal a script sees, or nil when the tool is callable. +func (u *upstreamToolCaller) policyRefusal(serverName, toolName string) error { + if u.proxy == nil || u.proxy.storage == nil { + return nil + } + gate := u.proxy.evaluateToolGate(serverName, toolName) + if gate.serverConfig == nil { + // Unknown server: leave the existing "server not found" path to answer. + return nil + } + if gate.callable() { + return nil + } + if gate.serverQuarantined() { + return fmt.Errorf("server %q is quarantined for security review; its tools cannot be called until it is approved", serverName) + } + switch gate.lockStatus { + case storage.ToolApprovalStatusPending: + return fmt.Errorf("tool %s:%s is pending security approval and cannot be called", serverName, toolName) + case storage.ToolApprovalStatusChanged: + return fmt.Errorf("tool %s:%s changed since approval and is locked pending review", serverName, toolName) + } + return fmt.Errorf("%s", gate.blockedMessage()) +} + // upstreamAnsweredWithError reports whether a dispatched result is an MCP // answer flagged isError:true — a failure the upstream reported over a // successful transport hop (issue #935). diff --git a/internal/server/mcp_direct_callability.go b/internal/server/mcp_direct_callability.go index d2618709..89a2fd46 100644 --- a/internal/server/mcp_direct_callability.go +++ b/internal/server/mcp_direct_callability.go @@ -8,6 +8,7 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" ) @@ -90,6 +91,11 @@ func (p *MCPProxyServer) directToolCallabilityBlock(ctx context.Context, serverN return p.directToolCallabilityResult(ctx, decision, args) } +// evaluate classifies one direct-mode tool through the SHARED gate primitive +// (Spec 098 FR-002) so direct mode, the call_tool_* variants, code_execution and +// stored scripts cannot disagree about what is callable. The memoized storage +// reads below still exist because this evaluator runs over a whole tool list; +// only the classification moved. func (e *directCallabilityEvaluator) evaluate(serverName, toolName string) directCallabilityDecision { decision := directCallabilityDecision{ serverName: serverName, @@ -106,14 +112,12 @@ func (e *directCallabilityEvaluator) evaluate(serverName, toolName string) direc return decision } decision.serverConfig = serverConfig - - if !serverConfig.Enabled || serverConfig.Quarantined { - return decision - } - - if e.proxy.isToolConfigDenied(serverName, toolName, serverConfig) { - decision.configDenied = true - return decision + configDenied := e.proxy.isToolConfigDenied(serverName, toolName, serverConfig) + // The server-level gates own the response when they fire, so the + // config-denial flag (which selects the operator-policy wording) is only + // surfaced once the server itself is past them — exactly as before. + if serverConfig.Enabled && !serverConfig.Quarantined { + decision.configDenied = configDenied } approval, approvalErr := e.getToolApproval(serverName, toolName) @@ -123,19 +127,30 @@ func (e *directCallabilityEvaluator) evaluate(serverName, toolName string) direc } decision.approval = approval - if e.proxy.config != nil && e.proxy.config.IsQuarantineEnabled() && !serverConfig.IsQuarantineSkipped() && approval != nil { + cfg := e.proxy.config + quarantineEnabled := cfg != nil && cfg.IsQuarantineEnabled() + // approvalStatus drives the RESPONSE shape only, and keeps the pre-098 + // preference for the pending/changed message over the generic block, so a + // refusal reads exactly as it always did. + if quarantineEnabled && !serverConfig.IsQuarantineSkipped() && approval != nil { switch approval.Status { case storage.ToolApprovalStatusPending, storage.ToolApprovalStatusChanged: decision.approvalStatus = approval.Status - return decision } } - if approval != nil && approval.Disabled { - return decision - } - - decision.callable = true + class := preflight.ClassifyTool(preflight.ClassifyInputs{ + Server: preflight.ServerPolicy{ + Found: true, + Enabled: serverConfig.Enabled, + Quarantined: serverConfig.Quarantined, + AutoApproveToolChanges: serverConfig.IsQuarantineSkipped(), + }, + QuarantineEnabled: quarantineEnabled, + ConfigDenied: configDenied, + Approval: approvalStateFor(approval), + }) + decision.callable = class.Callable() return decision } diff --git a/internal/server/mcp_visibility.go b/internal/server/mcp_visibility.go index 0a05f064..c2f92f56 100644 --- a/internal/server/mcp_visibility.go +++ b/internal/server/mcp_visibility.go @@ -109,22 +109,21 @@ func (p *MCPProxyServer) indexedToolVisible(authCtx *auth.AuthContext, profileSc // server doesn't skip it. // // Returns "" when neither gate fires. +// +// Spec 098 FR-002: both gates now read from the shared toolGate primitive, so +// describe_tool, dispatch and the preflight evaluator consult exactly one +// evaluation of the quarantine/approval state. The gate order (server +// quarantine, then the tool-level lock) is unchanged. func (p *MCPProxyServer) describeGateReason(serverName, toolName string) string { - serverConfig, err := p.storage.GetUpstreamServer(serverName) - if err == nil && serverConfig != nil && serverConfig.Quarantined { + gate := p.evaluateToolGate(serverName, toolName) + if gate.serverQuarantined() { return visReasonServerQuarantined } - - if (p.config == nil || p.config.IsQuarantineEnabled()) && - serverConfig != nil && !serverConfig.IsQuarantineSkipped() { - if approval, aerr := p.storage.GetToolApproval(serverName, toolName); aerr == nil && approval != nil { - switch approval.Status { - case storage.ToolApprovalStatusPending: - return visReasonToolPendingApproval - case storage.ToolApprovalStatusChanged: - return visReasonToolChangedApproval - } - } + switch gate.lockStatus { + case storage.ToolApprovalStatusPending: + return visReasonToolPendingApproval + case storage.ToolApprovalStatusChanged: + return visReasonToolChangedApproval } return "" } diff --git a/internal/server/preflight_dispatch_parity_test.go b/internal/server/preflight_dispatch_parity_test.go new file mode 100644 index 00000000..84e2e506 --- /dev/null +++ b/internal/server/preflight_dispatch_parity_test.go @@ -0,0 +1,292 @@ +package server + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" +) + +// FR-002 (no-skew). Two guarantees, tested separately because they are NOT the +// same strength: +// +// - SHARED POLICY GATES (quarantine, approval, user/config disablement, +// server disablement) are two-way: dispatch refuses ⇔ preflight says +// unavailable, with the reason the taxonomy names for that state. +// - EXISTENCE is one-way only: an unindexed tool preflights as `not_found` +// while dispatch stays deliberately fail-open. A non-ready preflight must +// therefore never be read as "dispatch would refuse". +type parityCase struct { + name string + // setup installs the sabotage for this cell. + setup func(t *testing.T, f *preflightFixture) + // dispatchRefuses is the expected dispatch decision for the shared gates. + dispatchRefuses bool + // reason is the expected preflight reason ("" ⇒ ready). + reason preflight.Reason +} + +func parityCases() []parityCase { + const server, tool = "gh", "create_issue" + + return []parityCase{ + { + name: "ready", + setup: func(t *testing.T, f *preflightFixture) { + f.addServer(t, &config.ServerConfig{Name: server, Enabled: true, Protocol: "http"}) + }, + dispatchRefuses: false, + }, + { + name: "server_disabled", + setup: func(t *testing.T, f *preflightFixture) { + f.addServer(t, &config.ServerConfig{Name: server, Enabled: false, Protocol: "http"}) + }, + dispatchRefuses: true, + reason: preflight.ReasonServerDisabled, + }, + { + name: "server_quarantined", + setup: func(t *testing.T, f *preflightFixture) { + f.addServer(t, &config.ServerConfig{Name: server, Enabled: true, Quarantined: true, Protocol: "http"}) + }, + dispatchRefuses: true, + reason: preflight.ReasonServerQuarantined, + }, + { + name: "tool_denied_by_config", + setup: func(t *testing.T, f *preflightFixture) { + f.addServer(t, &config.ServerConfig{ + Name: server, Enabled: true, Protocol: "http", + DisabledTools: []string{tool}, + }) + }, + dispatchRefuses: true, + reason: preflight.ReasonToolDeniedByConfig, + }, + { + name: "tool_blocked_by_user", + setup: func(t *testing.T, f *preflightFixture) { + f.addServer(t, &config.ServerConfig{Name: server, Enabled: true, Protocol: "http"}) + require.NoError(t, f.storage.SaveToolApproval(&storage.ToolApprovalRecord{ + ServerName: server, ToolName: tool, + Status: storage.ToolApprovalStatusApproved, Disabled: true, + })) + }, + dispatchRefuses: true, + reason: preflight.ReasonToolBlockedByUser, + }, + { + name: "tool_pending_approval", + setup: func(t *testing.T, f *preflightFixture) { + f.addServer(t, &config.ServerConfig{Name: server, Enabled: true, Protocol: "http"}) + require.NoError(t, f.storage.SaveToolApproval(&storage.ToolApprovalRecord{ + ServerName: server, ToolName: tool, + Status: storage.ToolApprovalStatusPending, + })) + }, + dispatchRefuses: true, + reason: preflight.ReasonToolPendingApproval, + }, + { + // The rug-pull guard is a class of its own — the pre-098 classifier + // collapsed it into pending_approval (research D2). + name: "tool_changed", + setup: func(t *testing.T, f *preflightFixture) { + f.addServer(t, &config.ServerConfig{Name: server, Enabled: true, Protocol: "http"}) + require.NoError(t, f.storage.SaveToolApproval(&storage.ToolApprovalRecord{ + ServerName: server, ToolName: tool, + Status: storage.ToolApprovalStatusChanged, + })) + }, + dispatchRefuses: true, + reason: preflight.ReasonToolChanged, + }, + { + // auto_approve_tool_changes (trust_mode auto) opts the server out of + // the tool-level quarantine gate: dispatch calls the tool, so the + // preflight must report it ready. + name: "auto_approve_tool_changes_makes_changed_ready", + setup: func(t *testing.T, f *preflightFixture) { + autoApprove := true + f.addServer(t, &config.ServerConfig{ + Name: server, Enabled: true, Protocol: "http", + AutoApproveToolChanges: &autoApprove, + }) + require.NoError(t, f.storage.SaveToolApproval(&storage.ToolApprovalRecord{ + ServerName: server, ToolName: tool, + Status: storage.ToolApprovalStatusChanged, + })) + }, + dispatchRefuses: false, + }, + { + // A user block is a user decision, not a quarantine gate: it applies + // even on an auto-approving server. + name: "auto_approve_still_honors_user_block", + setup: func(t *testing.T, f *preflightFixture) { + autoApprove := true + f.addServer(t, &config.ServerConfig{ + Name: server, Enabled: true, Protocol: "http", + AutoApproveToolChanges: &autoApprove, + }) + require.NoError(t, f.storage.SaveToolApproval(&storage.ToolApprovalRecord{ + ServerName: server, ToolName: tool, + Status: storage.ToolApprovalStatusApproved, Disabled: true, + })) + }, + dispatchRefuses: true, + reason: preflight.ReasonToolBlockedByUser, + }, + { + // Global quarantine off ⇒ pending records do not gate anything. + name: "quarantine_globally_disabled_makes_pending_ready", + setup: func(t *testing.T, f *preflightFixture) { + off := false + f.cfg.QuarantineEnabled = &off + f.addServer(t, &config.ServerConfig{Name: server, Enabled: true, Protocol: "http"}) + require.NoError(t, f.storage.SaveToolApproval(&storage.ToolApprovalRecord{ + ServerName: server, ToolName: tool, + Status: storage.ToolApprovalStatusPending, + })) + }, + dispatchRefuses: false, + }, + } +} + +func TestPreflightAndDispatchAgreeOnSharedPolicyGates(t *testing.T) { + const server, tool = "gh", "create_issue" + + for _, tc := range parityCases() { + t.Run(tc.name, func(t *testing.T) { + fixture := newPreflightFixture(t, nil) + tc.setup(t, fixture) + // Existence is a separate, one-way concern: index the tool so this + // case isolates the policy gates. + fixture.indexTool(t, server, tool) + + gate := fixture.proxy.evaluateToolGate(server, tool) + assert.Equal(t, !tc.dispatchRefuses, gate.callable(), "dispatch gate") + + // Direct mode is a separate dispatch path over the same primitive. + block := fixture.proxy.directToolCallabilityBlock(context.Background(), server, tool, map[string]interface{}{}) + assert.Equal(t, tc.dispatchRefuses, block != nil, "direct-mode dispatch") + + // The sandbox (code_execution + stored scripts) shares one bridge. + caller := &upstreamToolCaller{proxy: fixture.proxy} + assert.Equal(t, tc.dispatchRefuses, caller.policyRefusal(server, tool) != nil, "code_execution dispatch") + + out, err := fixture.proxy.RunPreflight(context.Background(), preflight.Params{ + Tools: []preflight.ToolRef{{ID: server + ":" + tool}}, + }) + require.NoError(t, err) + require.Len(t, out.Results, 1) + + if tc.dispatchRefuses { + assert.Equal(t, preflight.StatusUnavailable, out.Results[0].Status, + "a state dispatch refuses must never preflight as ready") + assert.Equal(t, tc.reason, out.Results[0].Reason) + } else { + assert.Equal(t, preflight.StatusReady, out.Results[0].Status, + "a callable tool must preflight as ready") + } + }) + } +} + +// One-way carve-out: dispatch is deliberately fail-open on existence, so an +// unindexed tool is `not_found` to a preflight and still callable to dispatch. +// Asserting equivalence here would be wrong — it is the documented asymmetry. +func TestPreflightExistenceGateIsOneWay(t *testing.T) { + fixture := newPreflightFixture(t, nil) + fixture.addServer(t, &config.ServerConfig{Name: "gh", Enabled: true, Protocol: "http"}) + // deliberately NOT indexed + + gate := fixture.proxy.evaluateToolGate("gh", "unindexed_tool") + assert.True(t, gate.callable(), "dispatch does not gate on index presence") + assert.Nil(t, (&upstreamToolCaller{proxy: fixture.proxy}).policyRefusal("gh", "unindexed_tool")) + + out, err := fixture.proxy.RunPreflight(context.Background(), preflight.Params{ + Tools: []preflight.ToolRef{{ID: "gh:unindexed_tool"}}, + }) + require.NoError(t, err) + assert.Equal(t, preflight.ReasonNotFound, out.Results[0].Reason) +} + +// The sandbox must stay fail-open for a server that has no stored record at all +// (in-process fixtures register upstreams without one), otherwise the +// consolidation would break script surfaces it was only meant to align. +func TestCodeExecutionGateFailsOpenForUnknownServer(t *testing.T) { + fixture := newPreflightFixture(t, nil) + caller := &upstreamToolCaller{proxy: fixture.proxy} + assert.NoError(t, caller.policyRefusal("never-configured", "some_tool")) + + // ... and with no proxy wired at all (bare unit-test callers). + assert.NoError(t, (&upstreamToolCaller{}).policyRefusal("gh", "create_issue")) +} + +// The consolidated classifier resolves the two research-D2 divergences in +// dispatch's favor. +func TestClassifyServerToolStatusHonorsQuarantineFlags(t *testing.T) { + t.Run("auto approving server is not reported pending", func(t *testing.T) { + fixture := newPreflightFixture(t, nil) + autoApprove := true + fixture.addServer(t, &config.ServerConfig{ + Name: "gh", Enabled: true, Protocol: "http", AutoApproveToolChanges: &autoApprove, + }) + require.NoError(t, fixture.storage.SaveToolApproval(&storage.ToolApprovalRecord{ + ServerName: "gh", ToolName: "create_issue", Status: storage.ToolApprovalStatusPending, + })) + assert.Equal(t, "", fixture.proxy.classifyServerToolStatus("gh", "create_issue")) + }) + + t.Run("quarantined server reports server_quarantined", func(t *testing.T) { + fixture := newPreflightFixture(t, nil) + fixture.addServer(t, &config.ServerConfig{Name: "gh", Enabled: true, Quarantined: true, Protocol: "http"}) + assert.Equal(t, "server_quarantined", fixture.proxy.classifyServerToolStatus("gh", "create_issue")) + }) +} + +// describe_tool's gate keeps its own reason vocabulary and its own ordering +// while reading the shared evaluation. +func TestDescribeGateReasonDelegatesToSharedGate(t *testing.T) { + fixture := newPreflightFixture(t, nil) + fixture.addServer(t, &config.ServerConfig{Name: "gh", Enabled: true, Protocol: "http"}) + require.NoError(t, fixture.storage.SaveToolApproval(&storage.ToolApprovalRecord{ + ServerName: "gh", ToolName: "changed_tool", Status: storage.ToolApprovalStatusChanged, + })) + require.NoError(t, fixture.storage.SaveToolApproval(&storage.ToolApprovalRecord{ + ServerName: "gh", ToolName: "pending_tool", Status: storage.ToolApprovalStatusPending, + })) + + assert.Equal(t, visReasonToolChangedApproval, fixture.proxy.describeGateReason("gh", "changed_tool")) + assert.Equal(t, visReasonToolPendingApproval, fixture.proxy.describeGateReason("gh", "pending_tool")) + assert.Equal(t, "", fixture.proxy.describeGateReason("gh", "plain_tool")) + + fixture.addServer(t, &config.ServerConfig{Name: "locked", Enabled: true, Quarantined: true, Protocol: "http"}) + assert.Equal(t, visReasonServerQuarantined, fixture.proxy.describeGateReason("locked", "anything")) +} + +// A user-disabled tool that is ALSO approval-locked still answers with its lock +// message on the dispatch paths (pre-098 ordering), even though the spec-098 +// precedence classifies it as blocked_by_user. The callability decision is +// shared; only the wording preference is path-local. +func TestDispatchKeepsLockMessagePreferenceForDisabledPendingTool(t *testing.T) { + fixture := newPreflightFixture(t, nil) + fixture.addServer(t, &config.ServerConfig{Name: "gh", Enabled: true, Protocol: "http"}) + require.NoError(t, fixture.storage.SaveToolApproval(&storage.ToolApprovalRecord{ + ServerName: "gh", ToolName: "create_issue", + Status: storage.ToolApprovalStatusPending, Disabled: true, + })) + + gate := fixture.proxy.evaluateToolGate("gh", "create_issue") + assert.False(t, gate.callable()) + assert.Equal(t, storage.ToolApprovalStatusPending, gate.lockStatus) + assert.Equal(t, preflight.ToolClassBlockedByUser, gate.class) +} diff --git a/internal/server/preflight_e2e_test.go b/internal/server/preflight_e2e_test.go new file mode 100644 index 00000000..2ca67884 --- /dev/null +++ b/internal/server/preflight_e2e_test.go @@ -0,0 +1,930 @@ +//go:build !windows + +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" +) + +// Spec 098 T026/T027 — the sabotage matrix, which is this feature's acceptance +// gate (FR-016, SC-001, SC-005, SC-006). +// +// The shape of this file, and why: +// +// - testdata/preflight_sabotage_matrix.json is the COMMITTED contract: +// scenario -> expected {status, reason, retryable, action, verdict, +// exit_code}. The Go code never hardcodes an expected reason; it looks the +// scenario up. Adding an enum code without a scenario fails +// TestPreflightSabotageMatrixCoversEveryReason, which needs no binary and +// therefore always runs in CI. +// - Every cell asserts the reason, the retryable flag and the action +// INDEPENDENTLY (three assertions, not one struct compare of a value the +// test itself built), plus the set verdict and the exit code the CLI would +// return for that verdict. +// - After every cell the test fetches the activity record by the response's +// X-Request-Id and asserts the preflight record exists with the right +// verdict and per-tool reason (SC-005). A cell that produced no auditable +// record is a failed cell. +// - The states are induced against a REAL mcpproxy binary with sabotaged +// upstream fixtures — quarantine flips, rug-pulls, kills — not against a +// hand-built evaluator context, because the point of the gate is that the +// wiring produces these verdicts, not that the evaluator can. + +const ( + preflightFixturePath = "testdata/preflight_fixture_server.js" + preflightE2EAPIKey = "preflight-e2e-api-key" +) + +// --------------------------------------------------------------------------- +// E2E harness: isolated mcpproxy binary + sabotageable fixture upstreams +// --------------------------------------------------------------------------- + +type preflightE2E struct { + t *testing.T + dir string + dataDir string + configPath string + binaryPath string + nodePath string + baseURL string + port int + cmd *exec.Cmd + scenarios map[string]sabotageScenario + // toolsFiles maps a fixture server name onto the JSON file whose contents it + // serves; rewriting one and restarting the server is the rug-pull. + toolsFiles map[string]string + // killFailFile is the fixture fail-switch for the `killable` server. + killFailFile string + client *http.Client +} + +// fixtureTool is one entry of a fixture tools file (MCP tool definition shape). +type fixtureTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]interface{} `json:"inputSchema"` + Annotations map[string]interface{} `json:"annotations,omitempty"` +} + +func emptySchema() map[string]interface{} { + return map[string]interface{}{"type": "object", "properties": map[string]interface{}{}} +} + +func readOnlyAnnotations() map[string]interface{} { + return map[string]interface{}{"readOnlyHint": true, "destructiveHint": false, "openWorldHint": false} +} + +// mainFixtureTools is the baseline definition set of the `main` server. +func mainFixtureTools() []fixtureTool { + return []fixtureTool{ + {Name: "ready_tool", Description: "ready v1", InputSchema: emptySchema(), Annotations: readOnlyAnnotations()}, + {Name: "drift_tool", Description: "drift v1", InputSchema: emptySchema(), Annotations: readOnlyAnnotations()}, + {Name: "blocked_tool", Description: "blockable", InputSchema: emptySchema(), Annotations: readOnlyAnnotations()}, + {Name: "noann_tool", Description: "no annotations at all", InputSchema: emptySchema()}, + { + Name: "unsafe_tool", Description: "explicitly unsafe", InputSchema: emptySchema(), + Annotations: map[string]interface{}{"readOnlyHint": false, "destructiveHint": true, "openWorldHint": true}, + }, + } +} + +func singleFixtureTool(name string) []fixtureTool { + return []fixtureTool{{Name: name, Description: name + " v1", InputSchema: emptySchema(), Annotations: readOnlyAnnotations()}} +} + +// newPreflightE2E prepares an ISOLATED instance: scratch data dir, scratch +// config, high port. It never touches ~/.mcpproxy. +func newPreflightE2E(t *testing.T) *preflightE2E { + t.Helper() + + if testing.Short() { + t.Skip("preflight sabotage E2E needs a built binary and a live proxy; skipped in -short") + } + nodePath, err := exec.LookPath("node") + if err != nil { + t.Skip("preflight sabotage E2E needs node for the fixture upstream") + } + binaryPath := preflightE2EBinary(t) + + dir := t.TempDir() + dataDir := filepath.Join(dir, "data") + require.NoError(t, os.MkdirAll(dataDir, 0o700)) + + env := &preflightE2E{ + t: t, + dir: dir, + dataDir: dataDir, + configPath: filepath.Join(dir, "config.json"), + binaryPath: binaryPath, + nodePath: nodePath, + port: preflightE2EPort(t), + scenarios: loadSabotageMatrix(t), + toolsFiles: map[string]string{}, + killFailFile: filepath.Join(dir, "killable.fail"), + client: &http.Client{Timeout: 30 * time.Second}, + } + env.baseURL = fmt.Sprintf("http://127.0.0.1:%d", env.port) + return env +} + +// preflightE2EBinary resolves the mcpproxy binary the same way the other binary +// E2Es do; a missing binary skips rather than fails, so the always-on reflection +// test above stays the CI gate for enum coverage. +func preflightE2EBinary(t *testing.T) string { + t.Helper() + + if explicit := os.Getenv("MCPPROXY_BINARY_PATH"); explicit != "" { + return explicit + } + const name = "mcpproxy" + cwd, err := os.Getwd() + require.NoError(t, err) + for dir := cwd; dir != filepath.Dir(dir); dir = filepath.Dir(dir) { + candidate := filepath.Join(dir, name) + if info, statErr := os.Stat(candidate); statErr == nil && !info.IsDir() { + return candidate + } + } + t.Skipf("mcpproxy binary not found; build it first: go build -o mcpproxy ./cmd/mcpproxy") + return "" +} + +// preflightE2EPort picks a free port in the 18xxx range so a stray instance can +// never collide with a developer's real proxy on 8080. +func preflightE2EPort(t *testing.T) int { + t.Helper() + for port := 18300; port < 18400; port++ { + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + continue + } + _ = ln.Close() + return port + } + t.Fatal("no free port in the 18300-18399 range") + return 0 +} + +func (e *preflightE2E) writeToolsFile(name string, tools []fixtureTool) string { + e.t.Helper() + path := filepath.Join(e.dir, name+"-tools.json") + raw, err := json.MarshalIndent(tools, "", " ") + require.NoError(e.t, err) + require.NoError(e.t, os.WriteFile(path, raw, 0o600)) + e.toolsFiles[name] = path + return path +} + +// fixtureServer builds one stdio upstream backed by the Node fixture. +func (e *preflightE2E) fixtureServer(name string, tools []fixtureTool, extraEnv map[string]string) map[string]interface{} { + e.t.Helper() + + fixture, err := filepath.Abs(preflightFixturePath) + require.NoError(e.t, err) + + serverEnv := map[string]string{"FIXTURE_TOOLS_FILE": e.writeToolsFile(name, tools)} + for k, v := range extraEnv { + serverEnv[k] = v + } + return map[string]interface{}{ + "name": name, + "protocol": "stdio", + "command": e.nodePath, + // The trailing --server marker is inert for the fixture and load-bearing + // for the test: every fixture runs the same script, so the argv marker is + // the only way to SIGKILL one specific upstream process. + "args": []string{fixture, "--server", name}, + "env": serverEnv, + // Explicitly untrusted-free: a server the proxy has never seen is + // admitted into quarantine by default, and a quarantined server is never + // connected or indexed, which would make every cell report + // server_quarantined. The matrix flips quarantine ON deliberately in its + // own cell instead. + "quarantined": false, + "enabled": true, + } +} + +// writeConfig lays out the sabotage fixtures. Each server exists for a specific +// group of cells, so one cell's sabotage cannot invalidate another's. +func (e *preflightE2E) writeConfig() { + e.t.Helper() + + mainServer := e.fixtureServer("main", mainFixtureTools(), nil) + + deniedServer := e.fixtureServer("denied", + []fixtureTool{ + {Name: "hidden_tool", Description: "denied by config", InputSchema: emptySchema(), Annotations: readOnlyAnnotations()}, + {Name: "visible_tool", Description: "allowed", InputSchema: emptySchema(), Annotations: readOnlyAnnotations()}, + }, nil) + deniedServer["disabled_tools"] = []string{"hidden_tool"} + + offlineServer := e.fixtureServer("offline", singleFixtureTool("offline_tool"), nil) + offlineServer["enabled"] = false + + killableServer := e.fixtureServer("killable", singleFixtureTool("killable_tool"), + map[string]string{"FIXTURE_FAIL_FILE": e.killFailFile}) + + slowServer := e.fixtureServer("slow", singleFixtureTool("slow_tool"), nil) + scopedServer := e.fixtureServer("scoped", singleFixtureTool("scoped_tool"), nil) + + cfg := map[string]interface{}{ + "listen": fmt.Sprintf("127.0.0.1:%d", e.port), + "data_dir": e.dataDir, + "api_key": preflightE2EAPIKey, + "enable_tray": false, + "enable_web_ui": false, + "debug_search": true, + "top_k": 10, + "tools_limit": 50, + "tool_response_limit": 20000, + "call_tool_timeout": "30s", + "quarantine_enabled": true, + "check_server_repo": false, + "docker_isolation": map[string]interface{}{"enabled": false}, + "mcpServers": []interface{}{ + mainServer, deniedServer, offlineServer, killableServer, slowServer, scopedServer, + }, + "profiles": []interface{}{ + map[string]interface{}{"name": "narrow", "servers": []string{"main"}}, + }, + } + + raw, err := json.MarshalIndent(cfg, "", " ") + require.NoError(e.t, err) + require.NoError(e.t, os.WriteFile(e.configPath, raw, 0o600)) +} + +func (e *preflightE2E) start() { + e.t.Helper() + + e.writeConfig() + + //nolint:gosec // test-only: the binary path is resolved from the repo, not from user input + cmd := exec.Command(e.binaryPath, "serve", "--config="+e.configPath, "--log-level=debug") + cmd.Env = append(os.Environ(), + "MCPPROXY_API_KEY="+preflightE2EAPIKey, + "MCPPROXY_DISABLE_OAUTH=true", + "HEADLESS=true", + ) + logFile, err := os.Create(filepath.Join(e.dir, "proxy.log")) + require.NoError(e.t, err) + cmd.Stdout = logFile + cmd.Stderr = logFile + // Own process group so the fixtures die with the proxy on cleanup. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + require.NoError(e.t, cmd.Start(), "failed to start mcpproxy") + e.cmd = cmd + + e.t.Cleanup(func() { + if e.cmd != nil && e.cmd.Process != nil { + _ = syscall.Kill(-e.cmd.Process.Pid, syscall.SIGTERM) + done := make(chan struct{}) + go func() { _, _ = e.cmd.Process.Wait(); close(done) }() + select { + case <-done: + case <-time.After(10 * time.Second): + _ = syscall.Kill(-e.cmd.Process.Pid, syscall.SIGKILL) + } + } + _ = logFile.Close() + if e.t.Failed() { + if data, readErr := os.ReadFile(filepath.Join(e.dir, "proxy.log")); readErr == nil { + tail := string(data) + if len(tail) > 20000 { + tail = tail[len(tail)-20000:] + } + e.t.Logf("proxy log tail:\n%s", tail) + } + } + }) + + e.waitUntil("HTTP API ready", 90*time.Second, func() bool { + status, _, err := e.get("/api/v1/servers") + return err == nil && status == http.StatusOK + }) +} + +func (e *preflightE2E) waitUntil(what string, timeout time.Duration, cond func() bool) { + e.t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(250 * time.Millisecond) + } + e.t.Fatalf("timed out waiting for %s after %s", what, timeout) +} + +// --------------------------------------------------------------------------- +// HTTP helpers +// --------------------------------------------------------------------------- + +func (e *preflightE2E) do(method, path string, body interface{}, apiKey string) (int, []byte, http.Header, error) { + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return 0, nil, nil, err + } + reader = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(context.Background(), method, e.baseURL+path, reader) + if err != nil { + return 0, nil, nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", apiKey) + resp, err := e.client.Do(req) + if err != nil { + return 0, nil, nil, err + } + defer resp.Body.Close() + payload, err := io.ReadAll(resp.Body) + return resp.StatusCode, payload, resp.Header, err +} + +func (e *preflightE2E) get(path string) (int, []byte, error) { + status, body, _, err := e.do(http.MethodGet, path, nil, preflightE2EAPIKey) + return status, body, err +} + +func (e *preflightE2E) post(path string, body interface{}) (int, []byte) { + e.t.Helper() + status, payload, _, err := e.do(http.MethodPost, path, body, preflightE2EAPIKey) + require.NoError(e.t, err) + return status, payload +} + +// preflightCall POSTs one request and returns the decoded response plus the +// X-Request-Id the activity assertion needs. +func (e *preflightE2E) preflightCall(req contracts.PreflightRequest, apiKey string) (int, contracts.PreflightResponse, string) { + e.t.Helper() + + status, payload, header, err := e.do(http.MethodPost, "/api/v1/preflight", req, apiKey) + require.NoError(e.t, err) + + var envelope struct { + Success bool `json:"success"` + Data contracts.PreflightResponse `json:"data"` + Error string `json:"error"` + } + if status == http.StatusOK { + require.NoError(e.t, json.Unmarshal(payload, &envelope), "preflight response body: %s", payload) + } + return status, envelope.Data, header.Get("X-Request-Id") +} + +func (e *preflightE2E) serverTools(serverName string) []contracts.Tool { + e.t.Helper() + status, payload, err := e.get("/api/v1/servers/" + serverName + "/tools") + require.NoError(e.t, err) + if status != http.StatusOK { + return nil + } + var envelope struct { + Data contracts.GetServerToolsResponse `json:"data"` + } + require.NoError(e.t, json.Unmarshal(payload, &envelope)) + return envelope.Data.Tools +} + +func (e *preflightE2E) toolRecord(serverName, toolName string) (contracts.Tool, bool) { + for _, tool := range e.serverTools(serverName) { + if tool.Name == toolName { + return tool, true + } + } + return contracts.Tool{}, false +} + +// activityForRequest returns the preflight activity record correlated with a +// request id, retrying briefly: the write is synchronous, but the read goes +// through a separate query path. +func (e *preflightE2E) activityForRequest(requestID string) (contracts.ActivityRecord, bool) { + e.t.Helper() + + deadline := time.Now().Add(10 * time.Second) + for { + status, payload, err := e.get("/api/v1/activity?request_id=" + requestID + "&limit=50") + if err == nil && status == http.StatusOK { + var envelope struct { + Data contracts.ActivityListResponse `json:"data"` + } + if json.Unmarshal(payload, &envelope) == nil { + for _, record := range envelope.Data.Activities { + if string(record.Type) == string(storage.ActivityTypePreflight) { + return record, true + } + } + } + } + if time.Now().After(deadline) { + return contracts.ActivityRecord{}, false + } + time.Sleep(200 * time.Millisecond) + } +} + +// --------------------------------------------------------------------------- +// Assertions +// --------------------------------------------------------------------------- + +// assertCell is the per-row assertion: exact reason, exact retryable, exact +// action, set verdict, CLI exit code, and the SC-005 activity record. Every +// expectation comes from the committed matrix. +func (e *preflightE2E) assertCell(scenario string, result contracts.PreflightToolResult, response contracts.PreflightResponse, requestID string) { + t := e.t + t.Helper() + + expect, ok := e.scenarios[scenario] + require.Truef(t, ok, "scenario %q is missing from %s", scenario, preflightMatrixPath) + + assert.Equalf(t, expect.Expect.Status, result.Status, "[%s] status", scenario) + if expect.Expect.Status == preflight.StatusReady { + assert.Emptyf(t, result.Reason, "[%s] a ready result carries no reason", scenario) + assert.Nilf(t, result.Retryable, "[%s] a ready result carries no retryable flag", scenario) + assert.Emptyf(t, result.Action, "[%s] a ready result carries no action", scenario) + } else { + assert.Equalf(t, expect.Expect.Reason, result.Reason, "[%s] reason", scenario) + if assert.NotNilf(t, result.Retryable, "[%s] a failure result must carry retryable", scenario) { + assert.Equalf(t, *expect.Expect.Retryable, *result.Retryable, "[%s] retryable", scenario) + } + assert.Equalf(t, expect.Expect.Action, result.Action, "[%s] action", scenario) + assert.NotEmptyf(t, result.Remediation, "[%s] a failure result must carry a remediation", scenario) + } + + assert.Equalf(t, expect.Expect.Verdict, response.Verdict, "[%s] set verdict", scenario) + assert.Equalf(t, expect.Expect.ExitCode, preflight.ExitCode(response.Verdict), "[%s] CLI exit code", scenario) + + // SC-005: the run must be discoverable by request id, with the same verdict + // and reason the caller was told. + require.NotEmptyf(t, requestID, "[%s] response carried no X-Request-Id", scenario) + record, found := e.activityForRequest(requestID) + require.Truef(t, found, "[%s] no preflight activity record for request %s", scenario, requestID) + assert.Equalf(t, expect.Expect.Verdict, record.Metadata[storage.MetadataKeyPreflightVerdict], + "[%s] activity record verdict", scenario) + + perTool, ok := record.Metadata[storage.MetadataKeyPreflightPerTool].([]interface{}) + require.Truef(t, ok, "[%s] activity record carries no per_tool payload", scenario) + found = false + for _, entry := range perTool { + row, isMap := entry.(map[string]interface{}) + if !isMap || row[storage.PreflightPerToolKeyID] != result.ID { + continue + } + found = true + assert.Equalf(t, result.Status, row[storage.PreflightPerToolKeyStatus], "[%s] activity per-tool status", scenario) + if expect.Expect.Status != preflight.StatusReady { + assert.Equalf(t, expect.Expect.Reason, row[storage.PreflightPerToolKeyReason], "[%s] activity per-tool reason", scenario) + } + } + assert.Truef(t, found, "[%s] activity record has no entry for %s", scenario, result.ID) +} + +// checkOne runs a single-tool preflight and asserts the matrix row for it. +func (e *preflightE2E) checkOne(scenario, id string, mutate func(*contracts.PreflightRequest)) contracts.PreflightToolResult { + e.t.Helper() + + req := contracts.PreflightRequest{Tools: []contracts.PreflightToolRef{{ID: id}}} + if mutate != nil { + mutate(&req) + } + status, response, requestID := e.preflightCall(req, preflightE2EAPIKey) + require.Equalf(e.t, http.StatusOK, status, "[%s] preflight must answer 200 whenever the check executed", scenario) + require.Lenf(e.t, response.Tools, 1, "[%s] one result per requested id", scenario) + + result := response.Tools[0] + assert.Equalf(e.t, id, result.ID, "[%s] result carries the requested id", scenario) + e.assertCell(scenario, result, response, requestID) + return result +} + +// awaitCell is checkOne for cells whose sabotage needs time to land (a restart, +// a reconnect, a re-index). It polls until the matrix's expected reason appears +// and then asserts THAT response — the one captured at the moment the state was +// observed — so a transient state (a server that is initializing now and errored +// a second later) cannot slip between the poll and the assertion. On timeout it +// asserts the last response anyway, so the failure names the reason actually +// seen. The polling decides WHEN to assert, never WHAT: every expectation still +// comes from the committed matrix. +func (e *preflightE2E) awaitCell(scenario, id string, timeout time.Duration) contracts.PreflightToolResult { + e.t.Helper() + + expect, ok := e.scenarios[scenario] + require.Truef(e.t, ok, "scenario %q is missing from %s", scenario, preflightMatrixPath) + + req := contracts.PreflightRequest{Tools: []contracts.PreflightToolRef{{ID: id}}} + var ( + response contracts.PreflightResponse + requestID string + ) + deadline := time.Now().Add(timeout) + for { + var status int + status, response, requestID = e.preflightCall(req, preflightE2EAPIKey) + require.Equalf(e.t, http.StatusOK, status, "[%s] preflight must answer 200 whenever the check executed", scenario) + require.Lenf(e.t, response.Tools, 1, "[%s] one result per requested id", scenario) + + if response.Tools[0].Reason == expect.Expect.Reason { + break + } + if time.Now().After(deadline) { + e.t.Logf("[%s] gave up waiting for %s; last reason was %q", + scenario, expect.Expect.Reason, response.Tools[0].Reason) + break + } + time.Sleep(250 * time.Millisecond) + } + + result := response.Tools[0] + assert.Equalf(e.t, id, result.ID, "[%s] result carries the requested id", scenario) + e.assertCell(scenario, result, response, requestID) + return result +} + +// waitForReason polls until a sabotage has propagated into the verdict, then +// returns. +func (e *preflightE2E) waitForReason(id, reason string, timeout time.Duration) { + e.t.Helper() + e.waitUntil(fmt.Sprintf("%s to report %s", id, reason), timeout, func() bool { + _, response, _ := e.preflightCall(contracts.PreflightRequest{ + Tools: []contracts.PreflightToolRef{{ID: id}}, + }, preflightE2EAPIKey) + return len(response.Tools) == 1 && response.Tools[0].Reason == reason + }) +} + +func (e *preflightE2E) waitForIndexedTools(serverName string, want int) { + e.t.Helper() + e.waitUntil(fmt.Sprintf("%s to index %d tools", serverName, want), 120*time.Second, func() bool { + return len(e.serverTools(serverName)) >= want + }) +} + +// --------------------------------------------------------------------------- +// The matrix, end to end +// --------------------------------------------------------------------------- + +func TestPreflightSabotageMatrixE2E(t *testing.T) { + env := newPreflightE2E(t) + env.start() + + // The healthy fixtures must be connected and indexed before any cell runs; + // otherwise a not_found would masquerade as a sabotage verdict. + env.waitForIndexedTools("main", len(mainFixtureTools())) + env.waitForIndexedTools("denied", 2) + env.waitForIndexedTools("killable", 1) + env.waitForIndexedTools("slow", 1) + env.waitUntil("main tools to be approved", 60*time.Second, func() bool { + tool, ok := env.toolRecord("main", "ready_tool") + return ok && tool.ApprovalStatus == string(storage.ToolApprovalStatusApproved) + }) + + // --- non-mutating cells ------------------------------------------------- + + t.Run("all_ready", func(t *testing.T) { + env.checkOne("all_ready", "main:ready_tool", nil) + }) + + t.Run("unknown_tool_id", func(t *testing.T) { + env.checkOne("unknown_tool_id", "main:ready_tolo", nil) + }) + + t.Run("unknown_server", func(t *testing.T) { + env.checkOne("unknown_server", "nosuchserver:whatever", nil) + }) + + t.Run("server_disable", func(t *testing.T) { + env.checkOne("server_disable", "offline:offline_tool", nil) + }) + + t.Run("config_denial", func(t *testing.T) { + env.checkOne("config_denial", "denied:hidden_tool", nil) + }) + + annotationCells := []struct { + name string + scenario string + id string + policy contracts.PreflightPolicy + }{ + // A tool that DOES declare the hints must survive every filter. These + // three rows are the regression guard for annotation enrichment: the + // Bleve documents carry no annotations, so a preflight reading the index + // alone would report missing_annotation for every tool and make + // policy_filtered unreachable. + {"annotated_ready_read_only_only", "all_ready", "main:ready_tool", contracts.PreflightPolicy{ReadOnlyOnly: true}}, + {"annotated_ready_exclude_destructive", "all_ready", "main:ready_tool", contracts.PreflightPolicy{ExcludeDestructive: true}}, + {"annotated_ready_exclude_open_world", "all_ready", "main:ready_tool", contracts.PreflightPolicy{ExcludeOpenWorld: true}}, + + {"missing_annotation_read_only_only", "missing_annotation_read_only_only", "main:noann_tool", contracts.PreflightPolicy{ReadOnlyOnly: true}}, + {"missing_annotation_exclude_destructive", "missing_annotation_exclude_destructive", "main:noann_tool", contracts.PreflightPolicy{ExcludeDestructive: true}}, + {"missing_annotation_exclude_open_world", "missing_annotation_exclude_open_world", "main:noann_tool", contracts.PreflightPolicy{ExcludeOpenWorld: true}}, + {"policy_filtered_read_only_only", "policy_filtered_read_only_only", "main:unsafe_tool", contracts.PreflightPolicy{ReadOnlyOnly: true}}, + {"policy_filtered_exclude_destructive", "policy_filtered_exclude_destructive", "main:unsafe_tool", contracts.PreflightPolicy{ExcludeDestructive: true}}, + {"policy_filtered_exclude_open_world", "policy_filtered_exclude_open_world", "main:unsafe_tool", contracts.PreflightPolicy{ExcludeOpenWorld: true}}, + } + for _, cell := range annotationCells { + policy := cell.policy + scenario, id := cell.scenario, cell.id + t.Run(cell.name, func(t *testing.T) { + env.checkOne(scenario, id, func(req *contracts.PreflightRequest) { + req.Policy = &policy + }) + }) + } + + t.Run("hash_mismatch", func(t *testing.T) { + tool, ok := env.toolRecord("main", "ready_tool") + require.True(t, ok) + require.NotEmpty(t, tool.Hash, "the operator-tier tools payload must publish a pin to author from") + version, _, err := preflight.ParsePin(tool.Hash) + require.NoError(t, err) + bogus := preflight.FormatPin(version, strings.Repeat("0", 64)) + require.NotEqual(t, tool.Hash, bogus) + env.checkOne("hash_mismatch", "main:ready_tool", func(req *contracts.PreflightRequest) { + req.Tools[0].PinHash = bogus + }) + }) + + t.Run("hash_mismatch_schema_version_bump", func(t *testing.T) { + tool, ok := env.toolRecord("main", "ready_tool") + require.True(t, ok) + version, hash, err := preflight.ParsePin(tool.Hash) + require.NoError(t, err) + // Same digest, different schema version: a proxy-side hash-algorithm + // bump must be reported as hash_mismatch, distinguished only in detail. + env.checkOne("hash_mismatch_schema_version_bump", "main:ready_tool", func(req *contracts.PreflightRequest) { + req.Tools[0].PinHash = preflight.FormatPin(version+1, hash) + }) + }) + + t.Run("profile_out_of_scope_operator", func(t *testing.T) { + result := env.checkOne("profile_out_of_scope_operator", "scoped:scoped_tool", func(req *contracts.PreflightRequest) { + req.Profile = "narrow" + }) + assert.Contains(t, result.Detail, "narrow", "the operator tier gets the full diagnosis") + }) + + t.Run("profile_out_of_scope_agent_token", func(t *testing.T) { + token := env.createAgentToken("preflight-scoped", []string{"main"}) + + status, response, requestID := env.preflightCall(contracts.PreflightRequest{ + Tools: []contracts.PreflightToolRef{{ID: "scoped:scoped_tool"}}, + }, token) + require.Equal(t, http.StatusOK, status) + require.Len(t, response.Tools, 1) + env.assertCell("profile_out_of_scope_agent_token", response.Tools[0], response, requestID) + + // FR-013: the scope-silenced result must be byte-indistinguishable from + // an ordinary not_found for the same caller. + _, absent, _ := env.preflightCall(contracts.PreflightRequest{ + Tools: []contracts.PreflightToolRef{{ID: "main:definitely_absent_tool"}}, + }, token) + require.Len(t, absent.Tools, 1) + scoped := response.Tools[0] + ordinary := absent.Tools[0] + scoped.ID, ordinary.ID = "", "" + scoped.DidYouMean, ordinary.DidYouMean = nil, nil + scopedJSON, err := json.Marshal(scoped) + require.NoError(t, err) + ordinaryJSON, err := json.Marshal(ordinary) + require.NoError(t, err) + assert.JSONEq(t, string(ordinaryJSON), string(scopedJSON), + "an out-of-scope id must be indistinguishable from an absent one at the agent-token tier") + assert.Empty(t, response.Tools[0].DidYouMean, "no suggestion may cross the scope boundary") + assert.Empty(t, response.Tools[0].Hash, "no hash disclosure at the agent-token tier") + }) + + // --- mutating cells: each sabotages one dedicated server ---------------- + + t.Run("mid_indexing", func(t *testing.T) { + // Park an ALREADY-INDEXED server in its connecting/discovering state, so + // the index still knows the tool and only the connection is in flight. + // The window is bounded by the proxy's connect timeout, which is why this + // cell asserts the response captured DURING the wait (awaitCell) rather + // than making a fresh call afterwards. + env.patchServerEnv("slow", map[string]string{"FIXTURE_INIT_DELAY_MS": "600000"}) + env.awaitCell("mid_indexing", "slow:slow_tool", 90*time.Second) + }) + + t.Run("upstream_killed", func(t *testing.T) { + // Arm the fixture's fail switch first: the reconnect then dies too, so + // the unhealthy state is stable rather than flapping back to ready. + require.NoError(t, os.WriteFile(env.killFailFile, []byte("down"), 0o600)) + env.killFixtureProcess("killable") + // Force the reconnect instead of waiting out the proxy's backoff + // schedule: the sabotage is the dead upstream, not the timing. + env.restartServer("killable") + env.awaitCell("upstream_killed", "killable:killable_tool", 120*time.Second) + }) + + t.Run("tool_blocked_by_user", func(t *testing.T) { + status, payload := env.post("/api/v1/servers/main/tools/block", + map[string]interface{}{"tools": []string{"blocked_tool"}}) + require.Equalf(t, http.StatusOK, status, "block tools: %s", payload) + env.awaitCell("tool_blocked_by_user", "main:blocked_tool", 30*time.Second) + }) + + t.Run("tool_definition_drift_and_new_tool", func(t *testing.T) { + // One rug-pull covers two cells: an existing tool's definition changes + // (rug-pull guard) and a brand-new tool appears after the trusted + // server's baseline was recorded. + mutated := mainFixtureTools() + for i := range mutated { + if mutated[i].Name == "drift_tool" { + mutated[i].Description = "drift v2: now also exfiltrates your credentials" + } + } + mutated = append(mutated, fixtureTool{ + Name: "new_tool", Description: "appeared after the baseline", + InputSchema: emptySchema(), Annotations: readOnlyAnnotations(), + }) + env.rewriteToolsFile("main", mutated) + env.restartServer("main") + + env.awaitCell("tool_definition_drift", "main:drift_tool", 120*time.Second) + env.awaitCell("new_tool_after_baseline", "main:new_tool", 120*time.Second) + }) + + // Quarantine hides everything on the server, so it runs last. + t.Run("quarantine_flip", func(t *testing.T) { + status, payload := env.post("/api/v1/servers/main/quarantine", nil) + require.Equalf(t, http.StatusOK, status, "quarantine: %s", payload) + env.awaitCell("quarantine_flip", "main:ready_tool", 60*time.Second) + }) +} + +// --------------------------------------------------------------------------- +// Sabotage helpers +// --------------------------------------------------------------------------- + +func (e *preflightE2E) rewriteToolsFile(serverName string, tools []fixtureTool) { + e.t.Helper() + path, ok := e.toolsFiles[serverName] + require.Truef(e.t, ok, "no tools file for %s", serverName) + raw, err := json.MarshalIndent(tools, "", " ") + require.NoError(e.t, err) + require.NoError(e.t, os.WriteFile(path, raw, 0o600)) +} + +func (e *preflightE2E) restartServer(serverName string) { + e.t.Helper() + status, payload := e.post("/api/v1/servers/"+serverName+"/restart", nil) + require.Equalf(e.t, http.StatusOK, status, "restart %s: %s", serverName, payload) +} + +// patchServerEnv rewrites one fixture server's environment — and thereby its +// behaviour on the next connect — then restarts it. The PATCH alone only marks +// the server as needing a restart ("restart_required"); without the explicit +// restart the OLD process keeps serving with the OLD environment and the +// sabotage silently does nothing. +func (e *preflightE2E) patchServerEnv(serverName string, extra map[string]string) { + e.t.Helper() + + env := map[string]string{"FIXTURE_TOOLS_FILE": e.toolsFiles[serverName]} + for k, v := range extra { + env[k] = v + } + status, payload, _, err := e.do(http.MethodPatch, "/api/v1/servers/"+serverName, + map[string]interface{}{"env": env}, preflightE2EAPIKey) + require.NoError(e.t, err) + require.Equalf(e.t, http.StatusOK, status, "patch %s env: %s", serverName, payload) + + e.restartServer(serverName) +} + +// killFixtureProcess SIGKILLs the node fixture serving one server, simulating an +// upstream that dies under the proxy. Every fixture runs the same script, so the +// process is identified by the "--server " argv marker. +func (e *preflightE2E) killFixtureProcess(serverName string) { + e.t.Helper() + + //nolint:gosec // test-only process lookup + out, err := exec.Command("/bin/sh", "-c", + fmt.Sprintf("ps -eo pid,command | grep -F -- %q | grep -v grep | awk '{print $1}'", + "--server "+serverName)).Output() + require.NoError(e.t, err) + + killed := 0 + for _, line := range strings.Fields(string(out)) { + var pid int + if _, scanErr := fmt.Sscanf(line, "%d", &pid); scanErr != nil || pid <= 0 { + continue + } + if syscall.Kill(pid, syscall.SIGKILL) == nil { + killed++ + } + } + require.Positivef(e.t, killed, "found no fixture process to kill for %s", serverName) +} + +// createAgentToken mints a scoped agent token and returns its raw value. +func (e *preflightE2E) createAgentToken(name string, allowedServers []string) string { + e.t.Helper() + + status, payload := e.post("/api/v1/tokens", map[string]interface{}{ + "name": name, + "allowed_servers": allowedServers, + "permissions": []string{"read"}, + }) + require.Containsf(e.t, []int{http.StatusOK, http.StatusCreated}, status, "create token: %s", payload) + + var envelope struct { + Data struct { + Token string `json:"token"` + } `json:"data"` + } + require.NoError(e.t, json.Unmarshal(payload, &envelope)) + require.NotEmpty(e.t, envelope.Data.Token) + return envelope.Data.Token +} + +// --------------------------------------------------------------------------- +// T027 — scripted incident diagnosis (SC-006) +// --------------------------------------------------------------------------- + +// TestPreflightIncidentDiagnosisE2E replays the incident class this feature +// exists for: a nightly job worked yesterday, someone quarantined the server +// overnight, and today the agent silently fails to find its tool. The preflight +// must name the root cause in ONE step — a single call, no log spelunking — and +// the activity record must let the operator diagnose it the next morning +// without server logs. +func TestPreflightIncidentDiagnosisE2E(t *testing.T) { + env := newPreflightE2E(t) + env.start() + env.waitForIndexedTools("main", len(mainFixtureTools())) + + const id = "main:ready_tool" + + // Run 1 — yesterday's healthy nightly job. + status, first, firstRequestID := env.preflightCall(contracts.PreflightRequest{ + Tools: []contracts.PreflightToolRef{{ID: id}}, + }, preflightE2EAPIKey) + require.Equal(t, http.StatusOK, status) + require.Len(t, first.Tools, 1) + require.Equal(t, preflight.StatusReady, first.Tools[0].Status, "the baseline run must be green") + require.Equal(t, preflight.VerdictReady, first.Verdict) + require.Equal(t, preflight.ExitReady, preflight.ExitCode(first.Verdict)) + + // Overnight incident. + quarantineStatus, payload := env.post("/api/v1/servers/main/quarantine", nil) + require.Equalf(t, http.StatusOK, quarantineStatus, "quarantine: %s", payload) + env.waitForReason(id, preflight.ReasonServerQuarantined, 60*time.Second) + + // Run 2 — one step to a named root cause. + status, second, secondRequestID := env.preflightCall(contracts.PreflightRequest{ + Tools: []contracts.PreflightToolRef{{ID: id}}, + }, preflightE2EAPIKey) + require.Equal(t, http.StatusOK, status) + require.Len(t, second.Tools, 1) + + result := second.Tools[0] + assert.Equal(t, preflight.StatusUnavailable, result.Status) + assert.Equal(t, preflight.ReasonServerQuarantined, result.Reason, "the root cause must be NAMED, not inferred") + if assert.NotNil(t, result.Retryable) { + assert.False(t, *result.Retryable, "quarantine is not fixed by waiting") + } + assert.Equal(t, "approve", result.Action) + assert.NotEmpty(t, result.Remediation, "the operator must be told what to do") + assert.Equal(t, preflight.VerdictBlocked, second.Verdict) + assert.Equal(t, preflight.ExitBlocked, preflight.ExitCode(second.Verdict), + "a cron wrapper must be able to branch on the exit code alone") + + // SC-005: both runs are diagnosable from the activity log alone. + assert.NotEqual(t, firstRequestID, secondRequestID, "each run gets its own request id") + before, ok := env.activityForRequest(firstRequestID) + require.True(t, ok, "the green run must be in the activity log") + assert.Equal(t, preflight.VerdictReady, before.Metadata[storage.MetadataKeyPreflightVerdict]) + + after, ok := env.activityForRequest(secondRequestID) + require.True(t, ok, "the failed run must be in the activity log") + assert.Equal(t, preflight.VerdictBlocked, after.Metadata[storage.MetadataKeyPreflightVerdict]) + reasons, ok := after.Metadata[storage.MetadataKeyPreflightReasons].(map[string]interface{}) + require.True(t, ok, "the record must carry the reason rollup") + assert.Contains(t, reasons, preflight.ReasonServerQuarantined, + "the activity record alone must name the root cause") +} diff --git a/internal/server/preflight_glue.go b/internal/server/preflight_glue.go new file mode 100644 index 00000000..dab3708a --- /dev/null +++ b/internal/server/preflight_glue.go @@ -0,0 +1,400 @@ +package server + +import ( + "context" + "errors" + "fmt" + "strings" + + "go.uber.org/zap" + + "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/preflight" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime/stateview" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" +) + +// This file is the ONLY bridge between the pure evaluator in internal/preflight +// and this package's index / storage / stateview / config wiring. Everything the +// evaluator can see arrives through the four narrow read interfaces implemented +// below, which is what makes FR-006 (zero upstream I/O, zero runtime mutation) +// structural: none of these adapters holds an upstream manager, a client, or a +// writer, so no preflight code path can reach a transport even by mistake. +// +// Two deliberate omissions, both load-bearing: +// +// - serverToolNames is NOT used. It falls back to a live ListTools when the +// StateView snapshot is cold, which would turn a "stat-only" preflight into +// upstream I/O on exactly the servers most likely to be unhealthy. +// - index.Manager.ForProfile is NOT used. It lazily CREATES and caches a +// per-profile Bleve index — a mutation. Profile semantics here are +// "shared-index existence + profile scope filter" (FR-010, plan decision 8). + +// RunPreflight evaluates one preflight request against local state only. +// +// Errors: preflight.ErrRuntimeUnavailable when the process cannot evaluate +// honestly, preflight.ErrUnknownProfile for a profile the config does not +// define, and a wrapped infrastructure error for a failed index/storage/config +// read — the served surface maps those to 503 rather than fabricating a reason +// code (FR-006). +func (p *MCPProxyServer) RunPreflight(ctx context.Context, params preflight.Params) (preflight.Outcome, error) { + if p == nil || p.storage == nil || p.index == nil { + return preflight.Outcome{}, preflight.ErrRuntimeUnavailable + } + cfg := p.currentConfig() + if cfg == nil { + return preflight.Outcome{}, preflight.ErrRuntimeUnavailable + } + + scope, err := p.resolvePreflightScope(params) + if err != nil { + return preflight.Outcome{}, err + } + + tier := params.Tier + if tier == "" { + tier = preflight.TierOperator + } + + // ONE snapshot for the whole request: it supplies both the connection state + // and the tool annotations, so every tool in a batch is judged against the + // same instant and the annotation filters see exactly what the spec 094 + // discovery filters see. + state, annotations := p.preflightSnapshot() + + ec := preflight.EvalContext{ + Index: &preflightIndexReader{index: p.index, annotations: annotations}, + Approvals: &preflightApprovalReader{storage: p.storage}, + State: state, + Policy: &preflightConfigPolicy{proxy: p, cfg: cfg}, + Tier: tier, + Scope: scope, + Filters: params.Filters, + } + + results, err := preflight.Evaluate(ctx, ec, params.Tools) + if err != nil { + return preflight.Outcome{}, err + } + return preflight.Outcome{ + Verdict: preflight.VerdictForResults(results), + Results: results, + }, nil +} + +// resolvePreflightScope turns the request's profile NAMES into the effective +// evaluation scope: token scope ∩ token pin ∩ requested profile. +// +// A requested profile that does not exist is a caller error (400). A token pin +// that no longer matches a configured profile is warn-skipped instead, matching +// resolveActiveProfile: the operator removed a profile after minting the token, +// and failing every one of that agent's preflights would be worse than +// degrading to the token's own server scope. +func (p *MCPProxyServer) resolvePreflightScope(params preflight.Params) (*preflight.Scope, error) { + inputs := preflight.ScopeInputs{TokenServers: params.TokenServers} + + if pin := params.TokenProfilePin; pin != "" { + if scope := p.profileScopeForSlug(pin); scope != nil { + inputs.TokenPinName = pin + inputs.TokenPinServers = scope.AllowedServerNames() + } else if p.logger != nil { + p.logger.Warn("preflight: agent-token profile_pin no longer matches any configured profile; falling through", + zap.String("profile_pin", pin)) + } + } + + if name := params.Profile; name != "" { + scope := p.profileScopeForSlug(name) + if scope == nil { + return nil, fmt.Errorf("%w: %q", preflight.ErrUnknownProfile, name) + } + inputs.RequestedProfileName = name + inputs.RequestedProfileServers = scope.AllowedServerNames() + } + + return preflight.ResolveScope(inputs), nil +} + +// --------------------------------------------------------------------------- +// IndexReader +// --------------------------------------------------------------------------- + +type preflightIndexReader struct { + index *index.Manager + // annotations resolves a tool's MCP annotations from the connection-state + // snapshot. It is REQUIRED for the annotation-filter slot to work: the Bleve + // documents carry identity and text only (index.BleveIndex.GetToolsByServer + // hydrates name/description/params/hash), so a tool read back from the index + // always has nil Annotations. Without this, every filtered preflight would + // report missing_annotation — including for tools that do declare the hint — + // and policy_filtered would be unreachable. nil disables enrichment. + annotations func(serverName, toolName string) *config.ToolAnnotations +} + +func (r *preflightIndexReader) ToolsByServer(serverName string) ([]preflight.IndexedTool, error) { + tools, err := r.index.GetToolsByServer(serverName) + if err != nil { + return nil, fmt.Errorf("index read for server %q: %w", serverName, err) + } + out := make([]preflight.IndexedTool, 0, len(tools)) + for _, tool := range tools { + if tool == nil { + continue + } + entry := preflight.IndexedTool{Name: tool.Name, Annotations: tool.Annotations} + if entry.Annotations == nil && r.annotations != nil { + entry.Annotations = r.annotations(serverName, bareToolName(tool.Name)) + } + out = append(out, entry) + } + return out, nil +} + +// bareToolName strips the ":" prefix the index stores on canonical +// names. +func bareToolName(name string) string { + if idx := strings.Index(name, ":"); idx >= 0 { + return name[idx+1:] + } + return name +} + +func (r *preflightIndexReader) IndexedServerNames() ([]string, error) { + names, err := r.index.GetAllIndexedServerNames() + if err != nil { + return nil, fmt.Errorf("index server list: %w", err) + } + return names, nil +} + +// --------------------------------------------------------------------------- +// ApprovalReader +// --------------------------------------------------------------------------- + +type preflightApprovalReader struct { + storage *storage.Manager +} + +// ToolApproval maps the storage seam onto the evaluator's contract: "no record" +// is the implicit-approved default and must come back as (nil, nil), while a +// genuine BBolt failure must come back as an error so the request answers 503 +// instead of silently reporting a tool as approved. +func (r *preflightApprovalReader) ToolApproval(serverName, toolName string) (*preflight.ApprovalState, error) { + record, err := r.storage.GetToolApproval(serverName, toolName) + if err != nil { + if errors.Is(err, storage.ErrToolApprovalNotFound) { + return nil, nil + } + return nil, fmt.Errorf("tool approval read for %s:%s: %w", serverName, toolName, err) + } + if record == nil { + return nil, nil + } + return &preflight.ApprovalState{ + Status: record.Status, + Disabled: record.Disabled, + CurrentHash: record.CurrentHash, + HashSchemaVersion: record.HashSchemaVersion, + }, nil +} + +// --------------------------------------------------------------------------- +// StateReader +// --------------------------------------------------------------------------- + +// preflightSnapshot takes ONE lock-free stateview snapshot for the whole +// request and derives both reads that need it: the connection state and the +// per-tool annotation lookup. Sharing the snapshot means every tool in a batch +// is judged against the same instant, and the annotations the filters see are +// the same ones the spec 094 discovery filters see. +// +// Both are nil when no supervisor is wired: the evaluator then makes no +// connection-state claim at all, which is honest, whereas a fabricated "ready" +// or "unhealthy" would not be. +func (p *MCPProxyServer) preflightSnapshot() (preflight.StateReader, func(serverName, toolName string) *config.ToolAnnotations) { + if p.mainServer == nil || p.mainServer.runtime == nil { + return nil, nil + } + supervisor := p.mainServer.runtime.Supervisor() + if supervisor == nil { + return nil, nil + } + view := supervisor.StateView() + if view == nil { + return nil, nil + } + snapshot := view.Snapshot() + if snapshot == nil { + return nil, nil + } + + servers := snapshot.Servers + annotations := func(serverName, toolName string) *config.ToolAnnotations { + status, ok := servers[serverName] + if !ok || status == nil { + return nil + } + for _, tool := range status.Tools { + // The snapshot stores bare names on the live path and canonical + // "server:tool" names when they came from ToolMetadata; match both. + if tool.Name == toolName || tool.Name == serverName+":"+toolName { + return tool.Annotations + } + } + return nil + } + return &preflightStateSnapshot{servers: servers}, annotations +} + +type preflightStateSnapshot struct { + servers map[string]*stateview.ServerStatus +} + +func (s *preflightStateSnapshot) ServerRuntime(serverName string) (preflight.ServerRuntime, bool) { + status, ok := s.servers[serverName] + if !ok || status == nil { + return preflight.ServerRuntime{}, false + } + state := preflightRuntimeState(status.State) + if state == preflight.RuntimeStateUnknown { + // An unmapped actor state ("idle", "unknown") is not evidence of + // anything: report "no entry" so the evaluator stays silent about the + // connection rather than guessing. + return preflight.ServerRuntime{}, false + } + return preflight.ServerRuntime{ + State: state, + Detail: preflightRuntimeDetail(status), + }, true +} + +// preflightRuntimeState maps the stateview's lowercased actor-state string +// (supervisor.updateStateView writes strings.ToLower(ConnectionState.String())) +// onto the evaluator's normalized vocabulary. +func preflightRuntimeState(state string) preflight.ServerRuntimeState { + switch strings.ToLower(strings.TrimSpace(state)) { + case "ready", "connected": + return preflight.RuntimeStateReady + case "connecting": + return preflight.RuntimeStateConnecting + case "discovering": + return preflight.RuntimeStateDiscovering + case "authenticating": + return preflight.RuntimeStateAuthenticating + case "pending auth", "pending_auth": + return preflight.RuntimeStatePendingAuth + case "disconnected": + return preflight.RuntimeStateDisconnected + case "error": + return preflight.RuntimeStateError + default: + // "idle" (disabled/quarantined servers, already caught by the config + // gates above the connection gates) and "unknown". + return preflight.RuntimeStateUnknown + } +} + +// preflightRuntimeDetail prefers the spec 044 classified diagnostic over the raw +// last error: it is the same text the health surfaces show, so an operator sees +// one explanation, not two. +func preflightRuntimeDetail(status *stateview.ServerStatus) string { + if status.Diagnostic != nil { + if status.Diagnostic.Remediation != "" { + return status.Diagnostic.Remediation + } + if status.Diagnostic.Cause != "" { + return status.Diagnostic.Cause + } + } + return status.LastError +} + +// --------------------------------------------------------------------------- +// ConfigPolicy +// --------------------------------------------------------------------------- + +type preflightConfigPolicy struct { + proxy *MCPProxyServer + cfg *config.Config + // servers memoizes the stored upstream record for the lifetime of ONE + // request. Beyond saving a BBolt read per gate, it gives the whole batch a + // consistent view: every tool in one preflight is judged against the same + // server record, even if the config changes mid-evaluation. + servers map[string]*config.ServerConfig +} + +func (c *preflightConfigPolicy) serverRecord(serverName string) *config.ServerConfig { + if c.servers == nil { + c.servers = make(map[string]*config.ServerConfig) + } + if record, ok := c.servers[serverName]; ok { + return record + } + record, err := c.proxy.storage.GetUpstreamServer(serverName) + if err != nil { + record = nil + } + c.servers[serverName] = record + return record +} + +// ServerPolicy reads the server record from STORAGE — the same authority the +// dispatch gates consult (config.db is authoritative), so preflight and dispatch +// cannot disagree about enabled/quarantined state. A missing record is +// Found:false, not an error. +func (c *preflightConfigPolicy) ServerPolicy(serverName string) (preflight.ServerPolicy, error) { + serverConfig := c.serverRecord(serverName) + if serverConfig == nil { + // The storage seam reports a missing upstream as an error, which is + // indistinguishable here from a read failure. Treating it as + // "not configured" matches every dispatch path (they all fail closed on + // the same signal), and the alternative — 503 on every typo'd server + // name — would be strictly worse. + return preflight.ServerPolicy{}, nil + } + return preflight.ServerPolicy{ + Found: true, + Enabled: serverConfig.Enabled, + Quarantined: serverConfig.Quarantined, + AutoApproveToolChanges: serverConfig.IsAutoApproveToolChanges(), + }, nil +} + +// ToolConfigDenied delegates to the single call-time authority so the +// enabled_tools/disabled_tools verdict is byte-identical to the one dispatch +// applies (it prefers the live runtime config, falling back to the stored +// record). +func (c *preflightConfigPolicy) ToolConfigDenied(serverName, toolName string) (bool, error) { + return c.proxy.isToolConfigDenied(serverName, toolName, c.serverRecord(serverName)), nil +} + +func (c *preflightConfigPolicy) QuarantineEnabled() bool { + return c.cfg.IsQuarantineEnabled() +} + +// --------------------------------------------------------------------------- +// ServerController surface +// --------------------------------------------------------------------------- + +// RunPreflight exposes the preflight evaluator on the ServerController surface +// the REST layer talks to (precedent: GetToolApprovalStatus). internal/httpapi +// never touches index/storage/stateview directly. +func (s *Server) RunPreflight(ctx context.Context, params preflight.Params) (preflight.Outcome, error) { + if s == nil || s.mcpProxy == nil { + return preflight.Outcome{}, preflight.ErrRuntimeUnavailable + } + return s.mcpProxy.RunPreflight(ctx, params) +} + +// RecordPreflight writes one preflight's activity record synchronously and +// returns the write error (Spec 098 FR-014). It is exposed on the controller +// surface because the served preflight must persist the record BEFORE it +// answers 200 — a failure here is a 503, not a logged warning. +func (s *Server) RecordPreflight(rec runtime.PreflightActivity) error { + if s == nil || s.runtime == nil { + return runtime.ErrActivityUnavailable + } + return s.runtime.RecordPreflight(rec) +} diff --git a/internal/server/preflight_glue_test.go b/internal/server/preflight_glue_test.go new file mode 100644 index 00000000..33d6a0a1 --- /dev/null +++ b/internal/server/preflight_glue_test.go @@ -0,0 +1,338 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sort" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/cache" + "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/preflight" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/secret" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/truncate" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream" +) + +// preflightFixture is a proxy wired to real storage + a real Bleve index, plus +// an INSTRUMENTED upstream: every HTTP request an upstream client would make +// increments upstreamHits. FR-006 says a preflight performs zero upstream I/O, +// and that is asserted here as a hard count, not as a threshold. +type preflightFixture struct { + proxy *MCPProxyServer + storage *storage.Manager + index *index.Manager + cfg *config.Config + upstreamHits *int64 +} + +func newPreflightFixture(t *testing.T, mutate func(cfg *config.Config)) *preflightFixture { + t.Helper() + + tmpDir := t.TempDir() + logger := zap.NewNop() + + sm, err := storage.NewManager(tmpDir, logger.Sugar()) + require.NoError(t, err) + t.Cleanup(func() { sm.Close() }) + + idx, err := index.NewManager(tmpDir, logger) + require.NoError(t, err) + t.Cleanup(func() { idx.Close() }) + + var hits int64 + upstreamSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt64(&hits, 1) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(upstreamSrv.Close) + + cfg := config.DefaultConfig() + cfg.DataDir = tmpDir + if mutate != nil { + mutate(cfg) + } + + um := upstream.NewManager(logger, cfg, nil, secret.NewResolver(), nil) + cm, err := cache.NewManager(sm.GetDB(), logger) + require.NoError(t, err) + t.Cleanup(func() { cm.Close() }) + + tr := truncate.NewTruncator(0) + proxy := NewMCPProxyServer(sm, idx, um, cm, func() *truncate.Truncator { return tr }, logger, nil, false, cfg, nil) + + // Register the instrumented upstream so a stray connect/ListTools would be + // visible in the hit counter rather than silently unreachable. + require.NoError(t, um.AddServerConfig("gh", &config.ServerConfig{ + Name: "gh", + URL: upstreamSrv.URL, + Protocol: "http", + Enabled: true, + })) + + return &preflightFixture{proxy: proxy, storage: sm, index: idx, cfg: cfg, upstreamHits: &hits} +} + +func (f *preflightFixture) addServer(t *testing.T, sc *config.ServerConfig) { + t.Helper() + require.NoError(t, f.storage.SaveUpstreamServer(sc)) + // Profiles resolve their members against the live config (EffectiveServers + // drops names that are not configured servers), so the fixture keeps the two + // in sync exactly as the real config/storage pair does. + f.cfg.Servers = append(f.cfg.Servers, sc) +} + +func (f *preflightFixture) indexTool(t *testing.T, serverName, toolName string) { + t.Helper() + require.NoError(t, f.index.IndexTool(&config.ToolMetadata{ + Name: serverName + ":" + toolName, + ServerName: serverName, + Description: "fixture tool", + ParamsJSON: `{"type":"object"}`, + Hash: "hash-" + toolName, + Created: time.Now(), + Updated: time.Now(), + })) +} + +// stateSnapshot is the observable proxy state a preflight must leave untouched: +// upstream records, tool approvals, the shared index contents, the per-profile +// index directories (ForProfile would create one) and the live config. +type stateSnapshot struct { + Servers string + Approvals string + IndexedGH string + IndexCount uint64 + ProfileDirs []string + Config string +} + +func (f *preflightFixture) snapshot(t *testing.T) stateSnapshot { + t.Helper() + + servers, err := f.storage.ListUpstreams() + require.NoError(t, err) + serversJSON, err := json.Marshal(servers) + require.NoError(t, err) + + approvals, err := f.storage.ListToolApprovals("gh") + require.NoError(t, err) + approvalsJSON, err := json.Marshal(approvals) + require.NoError(t, err) + + tools, err := f.index.GetToolsByServer("gh") + require.NoError(t, err) + toolsJSON, err := json.Marshal(tools) + require.NoError(t, err) + + count, err := f.index.GetDocumentCount() + require.NoError(t, err) + + dirs, err := f.index.ExistingProfileDirs() + require.NoError(t, err) + sort.Strings(dirs) + + cfgJSON, err := json.Marshal(f.cfg) + require.NoError(t, err) + + return stateSnapshot{ + Servers: string(serversJSON), + Approvals: string(approvalsJSON), + IndexedGH: string(toolsJSON), + IndexCount: count, + ProfileDirs: dirs, + Config: string(cfgJSON), + } +} + +func resultByID(t *testing.T, out preflight.Outcome, id string) preflight.Result { + t.Helper() + for _, res := range out.Results { + if res.ID == id { + return res + } + } + t.Fatalf("no result for id %q in %+v", id, out.Results) + return preflight.Result{} +} + +// FR-006: a preflight must perform zero upstream calls AND mutate nothing — +// including the per-profile Bleve indexes, which index.Manager.ForProfile would +// lazily create (which is exactly why the glue never calls it). +func TestRunPreflightPerformsNoUpstreamIOAndNoMutation(t *testing.T) { + fixture := newPreflightFixture(t, func(cfg *config.Config) { + cfg.Profiles = []config.ProfileConfig{{Name: "ops", Servers: []string{"gh"}}} + }) + fixture.addServer(t, &config.ServerConfig{Name: "gh", Enabled: true, Protocol: "http"}) + fixture.addServer(t, &config.ServerConfig{Name: "locked", Enabled: true, Quarantined: true, Protocol: "http"}) + fixture.indexTool(t, "gh", "create_issue") + require.NoError(t, fixture.storage.SaveToolApproval(&storage.ToolApprovalRecord{ + ServerName: "gh", + ToolName: "create_issue", + Status: storage.ToolApprovalStatusApproved, + CurrentHash: "abc123", + HashSchemaVersion: 2, + })) + + before := fixture.snapshot(t) + + out, err := fixture.proxy.RunPreflight(context.Background(), preflight.Params{ + Tools: []preflight.ToolRef{ + {ID: "gh:create_issue"}, + {ID: "gh:missing_tool"}, + {ID: "locked:anything"}, + {ID: "nosuch:tool"}, + {ID: "malformed-id"}, + }, + Profile: "ops", + }) + require.NoError(t, err) + + // Sanity: the evaluation really ran (otherwise "no mutation" is vacuous). + require.Len(t, out.Results, 5) + assert.Equal(t, preflight.StatusReady, resultByID(t, out, "gh:create_issue").Status) + assert.Equal(t, preflight.ReasonNotFound, resultByID(t, out, "gh:missing_tool").Reason) + // "locked" is outside the requested profile, so scope wins over quarantine + // per the FR-004 precedence chain. + assert.Equal(t, preflight.ReasonServerNotInScope, resultByID(t, out, "locked:anything").Reason) + assert.Equal(t, preflight.ReasonServerNotConfigured, resultByID(t, out, "nosuch:tool").Reason) + assert.Equal(t, preflight.ReasonNotFound, resultByID(t, out, "malformed-id").Reason) + assert.Equal(t, preflight.VerdictUnknownIDs, out.Verdict) + + assert.Equal(t, int64(0), atomic.LoadInt64(fixture.upstreamHits), + "a preflight must never touch an upstream server") + + after := fixture.snapshot(t) + assert.Equal(t, before, after, "a preflight must not mutate runtime, index, config or approval state") + assert.Empty(t, after.ProfileDirs, "preflight must never call ForProfile (it creates a per-profile index)") +} + +// The operator tier names the scope failure; the agent-token tier must not be +// able to tell "exists but hidden" from "does not exist" (FR-013). +func TestRunPreflightScopeDisclosureByTier(t *testing.T) { + fixture := newPreflightFixture(t, func(cfg *config.Config) { + cfg.Profiles = []config.ProfileConfig{{Name: "ops", Servers: []string{"gh"}}} + }) + fixture.addServer(t, &config.ServerConfig{Name: "gh", Enabled: true, Protocol: "http"}) + fixture.addServer(t, &config.ServerConfig{Name: "secret", Enabled: true, Protocol: "http"}) + fixture.indexTool(t, "gh", "create_issue") + fixture.indexTool(t, "secret", "exfiltrate") + + operator, err := fixture.proxy.RunPreflight(context.Background(), preflight.Params{ + Tools: []preflight.ToolRef{{ID: "secret:exfiltrate"}}, + Profile: "ops", + }) + require.NoError(t, err) + assert.Equal(t, preflight.ReasonServerNotInScope, operator.Results[0].Reason) + + agent, err := fixture.proxy.RunPreflight(context.Background(), preflight.Params{ + Tools: []preflight.ToolRef{{ID: "secret:exfiltrate"}}, + Tier: preflight.TierAgentToken, + // The token is scoped to gh only; no profile requested. + TokenServers: []string{"gh"}, + }) + require.NoError(t, err) + assert.Equal(t, preflight.ReasonNotFound, agent.Results[0].Reason) + assert.Empty(t, agent.Results[0].Hash) + for _, suggestion := range agent.Results[0].DidYouMean { + assert.NotContains(t, suggestion, "secret:", "did_you_mean must never cross the scope boundary") + } +} + +// An agent token pinned to a profile cannot widen its scope by naming another +// one: the evaluation scope is the intersection (review finding 11). +func TestRunPreflightTokenPinIntersectsRequestedProfile(t *testing.T) { + fixture := newPreflightFixture(t, func(cfg *config.Config) { + cfg.Profiles = []config.ProfileConfig{ + {Name: "ops", Servers: []string{"gh"}}, + {Name: "wide", Servers: []string{"gh", "secret"}}, + } + }) + fixture.addServer(t, &config.ServerConfig{Name: "gh", Enabled: true, Protocol: "http"}) + fixture.addServer(t, &config.ServerConfig{Name: "secret", Enabled: true, Protocol: "http"}) + fixture.indexTool(t, "gh", "create_issue") + fixture.indexTool(t, "secret", "exfiltrate") + + out, err := fixture.proxy.RunPreflight(context.Background(), preflight.Params{ + Tools: []preflight.ToolRef{{ID: "gh:create_issue"}, {ID: "secret:exfiltrate"}}, + Tier: preflight.TierAgentToken, + TokenProfilePin: "ops", + Profile: "wide", + }) + require.NoError(t, err) + assert.Equal(t, preflight.StatusReady, resultByID(t, out, "gh:create_issue").Status) + assert.Equal(t, preflight.ReasonNotFound, resultByID(t, out, "secret:exfiltrate").Reason) +} + +func TestRunPreflightUnknownProfileIsACallerError(t *testing.T) { + fixture := newPreflightFixture(t, nil) + fixture.addServer(t, &config.ServerConfig{Name: "gh", Enabled: true, Protocol: "http"}) + + _, err := fixture.proxy.RunPreflight(context.Background(), preflight.Params{ + Tools: []preflight.ToolRef{{ID: "gh:create_issue"}}, + Profile: "nope", + }) + require.ErrorIs(t, err, preflight.ErrUnknownProfile) +} + +// A degraded process must refuse rather than emit reduced-fidelity verdicts. +func TestRunPreflightRuntimeUnavailable(t *testing.T) { + fixture := newPreflightFixture(t, nil) + + noIndex := fixture.proxy + savedIndex := noIndex.index + noIndex.index = nil + t.Cleanup(func() { noIndex.index = savedIndex }) + _, err := noIndex.RunPreflight(context.Background(), preflight.Params{Tools: []preflight.ToolRef{{ID: "gh:x"}}}) + require.ErrorIs(t, err, preflight.ErrRuntimeUnavailable) + + var nilServer *Server + _, err = nilServer.RunPreflight(context.Background(), preflight.Params{}) + require.ErrorIs(t, err, preflight.ErrRuntimeUnavailable) +} + +// Hash disclosure is operator-tier only, and a stale pin fails closed +// (FR-011/FR-013). +func TestRunPreflightHashDisclosureAndPins(t *testing.T) { + fixture := newPreflightFixture(t, nil) + fixture.addServer(t, &config.ServerConfig{Name: "gh", Enabled: true, Protocol: "http"}) + fixture.indexTool(t, "gh", "create_issue") + require.NoError(t, fixture.storage.SaveToolApproval(&storage.ToolApprovalRecord{ + ServerName: "gh", + ToolName: "create_issue", + Status: storage.ToolApprovalStatusApproved, + CurrentHash: "abc123", + HashSchemaVersion: 2, + })) + + operator, err := fixture.proxy.RunPreflight(context.Background(), preflight.Params{ + Tools: []preflight.ToolRef{{ID: "gh:create_issue"}}, + }) + require.NoError(t, err) + assert.Equal(t, "sha256/v2:abc123", operator.Results[0].Hash) + + agent, err := fixture.proxy.RunPreflight(context.Background(), preflight.Params{ + Tools: []preflight.ToolRef{{ID: "gh:create_issue"}}, + Tier: preflight.TierAgentToken, + }) + require.NoError(t, err) + assert.Empty(t, agent.Results[0].Hash) + + stale, err := fixture.proxy.RunPreflight(context.Background(), preflight.Params{ + Tools: []preflight.ToolRef{{ID: "gh:create_issue", PinHash: "sha256/v2:deadbeef"}}, + }) + require.NoError(t, err) + assert.Equal(t, preflight.ReasonHashMismatch, stale.Results[0].Reason) + assert.Equal(t, preflight.VerdictBlocked, stale.Verdict) + assert.Equal(t, int64(0), atomic.LoadInt64(fixture.upstreamHits)) +} diff --git a/internal/server/preflight_index_shadowing_test.go b/internal/server/preflight_index_shadowing_test.go new file mode 100644 index 00000000..1e5657cf --- /dev/null +++ b/internal/server/preflight_index_shadowing_test.go @@ -0,0 +1,120 @@ +package server + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" +) + +// Spec 098 — regression guard for the index-shadowing gap found by the T025 +// live no-skew E2E (and originally pinned here as a characterization test). +// +// The runtime REMOVES a tool from the shared Bleve index the moment it becomes +// user-blocked, pending, or changed (internal/runtime/lifecycle.go tool-index +// maintenance). Index absence alone therefore cannot prove nonexistence: a +// spec-032 approval record is authoritative evidence the tool exists upstream. +// The evaluator's existence source is index ∨ approval record, so the three +// approval-derived reasons below survive de-indexing instead of being shadowed +// by a misleading not_found (whose did_you_mean could even point at a +// DIFFERENT server's tool on a rug-pull — the worst possible hint). +// +// These tests reproduce the live ordering — server indexed, the sabotaged tool +// NOT indexed, approval record present — and assert the true reason reaches +// the caller while every dispatch path still refuses (FR-002 both directions). +func TestPreflightReasonSurvivesToolDeindexing(t *testing.T) { + const server = "gh" + + cases := []struct { + name string + // approval is the record the runtime leaves behind for the sabotage. + approval *storage.ToolApprovalRecord + // want is the reason FR-004 names for this state; it must NOT be + // shadowed by not_found even though the tool is absent from the index. + want preflight.Reason + }{ + { + name: "tool_blocked_by_user", + approval: &storage.ToolApprovalRecord{ + ServerName: server, ToolName: "create_issue", + Status: storage.ToolApprovalStatusApproved, Disabled: true, + }, + want: preflight.ReasonToolBlockedByUser, + }, + { + name: "tool_pending_approval", + approval: &storage.ToolApprovalRecord{ + ServerName: server, ToolName: "create_issue", + Status: storage.ToolApprovalStatusPending, + }, + want: preflight.ReasonToolPendingApproval, + }, + { + name: "tool_changed", + approval: &storage.ToolApprovalRecord{ + ServerName: server, ToolName: "create_issue", + Status: storage.ToolApprovalStatusChanged, + }, + want: preflight.ReasonToolChanged, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fixture := newPreflightFixture(t, nil) + fixture.addServer(t, &config.ServerConfig{Name: server, Enabled: true, Protocol: "http"}) + // The server is in the index; the sabotaged tool is not — exactly + // what a live instance looks like after the block/pending/changed + // transition de-indexes it. + fixture.indexTool(t, server, "list_issues") + require.NoError(t, fixture.storage.SaveToolApproval(tc.approval)) + + // Every dispatch path still refuses: the FR-002 callability + // guarantee holds regardless of the index. + gate := fixture.proxy.evaluateToolGate(server, "create_issue") + assert.False(t, gate.callable(), "dispatch gate must refuse") + assert.NotNil(t, + fixture.proxy.directToolCallabilityBlock(context.Background(), server, "create_issue", map[string]interface{}{}), + "direct-mode dispatch must refuse") + assert.Error(t, + (&upstreamToolCaller{proxy: fixture.proxy}).policyRefusal(server, "create_issue"), + "code_execution / stored-script bridge must refuse") + + out, err := fixture.proxy.RunPreflight(context.Background(), preflight.Params{ + Tools: []preflight.ToolRef{{ID: server + ":create_issue"}}, + }) + require.NoError(t, err) + require.Len(t, out.Results, 1) + + // Agreement on the DECISION... + assert.Equal(t, preflight.StatusUnavailable, out.Results[0].Status) + // ...AND on the reason: the approval record proves existence, so + // the taxonomy code that names this state reaches the caller. + assert.Equal(t, tc.want, out.Results[0].Reason, + "approval-derived reason must survive de-indexing (not_found would be misleading)") + assert.Empty(t, out.Results[0].DidYouMean, + "a known tool must never carry a did_you_mean suggestion") + }) + } +} + +// A truly unknown id (no index entry AND no approval record) still reports +// not_found on a Ready server — the fix must not widen existence beyond the +// two authoritative sources. +func TestPreflightUnknownToolStillNotFound(t *testing.T) { + fixture := newPreflightFixture(t, nil) + fixture.addServer(t, &config.ServerConfig{Name: "gh", Enabled: true, Protocol: "http"}) + fixture.indexTool(t, "gh", "list_issues") + + out, err := fixture.proxy.RunPreflight(context.Background(), preflight.Params{ + Tools: []preflight.ToolRef{{ID: "gh:create_issue"}}, + }) + require.NoError(t, err) + require.Len(t, out.Results, 1) + assert.Equal(t, preflight.ReasonNotFound, out.Results[0].Reason) +} diff --git a/internal/server/preflight_matrix_test.go b/internal/server/preflight_matrix_test.go new file mode 100644 index 00000000..460f0b7b --- /dev/null +++ b/internal/server/preflight_matrix_test.go @@ -0,0 +1,192 @@ +package server + +import ( + "context" + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" +) + +// Spec 098 T026 — the committed sabotage matrix and the checks over it that must +// run EVERYWHERE, including hosts where the binary-driven E2E +// (preflight_e2e_test.go) cannot run. Keeping the reflection check here is the +// point: FR-016 says adding an enum code without its cell must fail CI, and a +// gate that only fires when a binary and node happen to be present is not a +// gate. + +const preflightMatrixPath = "testdata/preflight_sabotage_matrix.json" + +type sabotageExpectation struct { + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + // Retryable is a pointer so "absent" (a ready row) is distinguishable from + // an explicit false. + Retryable *bool `json:"retryable,omitempty"` + Action string `json:"action,omitempty"` + Verdict string `json:"verdict"` + ExitCode int `json:"exit_code"` +} + +type sabotageScenario struct { + Scenario string `json:"scenario"` + Surface string `json:"surface"` + Sabotage string `json:"sabotage"` + Expect sabotageExpectation `json:"expect"` +} + +type sabotageMatrix struct { + Scenarios []sabotageScenario `json:"scenarios"` +} + +func loadSabotageMatrix(t *testing.T) map[string]sabotageScenario { + t.Helper() + + raw, err := os.ReadFile(preflightMatrixPath) + require.NoError(t, err, "the committed sabotage matrix must be readable") + + var matrix sabotageMatrix + require.NoError(t, json.Unmarshal(raw, &matrix), "sabotage matrix must be valid JSON") + require.NotEmpty(t, matrix.Scenarios) + + byName := make(map[string]sabotageScenario, len(matrix.Scenarios)) + for _, scenario := range matrix.Scenarios { + require.NotEmpty(t, scenario.Scenario, "every scenario needs a key") + _, dup := byName[scenario.Scenario] + require.Falsef(t, dup, "duplicate scenario key %q", scenario.Scenario) + byName[scenario.Scenario] = scenario + } + return byName +} + +// TestPreflightSabotageMatrixCoversEveryReason is the reflection check FR-016 +// demands: every code of the closed enum owns at least one scenario, and every +// scenario's expectation agrees with the normative FR-003 taxonomy (so the +// matrix can never quietly encode a wrong retryable flag or action and then be +// "confirmed" by an E2E that reads its expectations from it). +func TestPreflightSabotageMatrixCoversEveryReason(t *testing.T) { + scenarios := loadSabotageMatrix(t) + + covered := make(map[string]int) + for name, scenario := range scenarios { + expect := scenario.Expect + require.Containsf(t, []string{preflight.StatusReady, preflight.StatusUnavailable}, + expect.Status, "scenario %q: status must be a valid preflight status", name) + require.NotEmptyf(t, scenario.Surface, "scenario %q: surface must say how the state is induced", name) + require.NotEmptyf(t, scenario.Sabotage, "scenario %q: sabotage must describe the induced state", name) + + if expect.Status == preflight.StatusReady { + assert.Emptyf(t, expect.Reason, "scenario %q: a ready row carries no reason", name) + assert.Nilf(t, expect.Retryable, "scenario %q: a ready row carries no retryable flag", name) + assert.Emptyf(t, expect.Action, "scenario %q: a ready row carries no action", name) + assert.Equalf(t, preflight.VerdictReady, expect.Verdict, "scenario %q", name) + assert.Equalf(t, preflight.ExitReady, expect.ExitCode, "scenario %q", name) + continue + } + + require.Truef(t, preflight.ValidReason(expect.Reason), + "scenario %q: %q is not a member of the closed reason enum", name, expect.Reason) + covered[expect.Reason]++ + + require.NotNilf(t, expect.Retryable, "scenario %q: a failure row must state retryable", name) + assert.Equalf(t, preflight.Retryable(expect.Reason), *expect.Retryable, + "scenario %q: retryable disagrees with the taxonomy for %s", name, expect.Reason) + assert.Equalf(t, preflight.DefaultAction(expect.Reason), expect.Action, + "scenario %q: action disagrees with the taxonomy for %s", name, expect.Reason) + assert.Equalf(t, preflight.ReasonVerdict(expect.Reason), expect.Verdict, + "scenario %q: verdict disagrees with the taxonomy for %s", name, expect.Reason) + assert.Equalf(t, preflight.ExitCode(expect.Verdict), expect.ExitCode, + "scenario %q: exit code disagrees with the taxonomy for %s", name, expect.Verdict) + } + + for _, reason := range preflight.AllReasons() { + assert.Positivef(t, covered[reason], + "reason %q has no scenario in %s: FR-016 requires a sabotage cell per enum code", + reason, preflightMatrixPath) + } +} + +// --------------------------------------------------------------------------- +// State-injected cells +// --------------------------------------------------------------------------- + +// TestPreflightSabotageMatrixPendingAuthCell covers the one matrix row whose +// state cannot be induced from outside a running proxy deterministically: the +// deferred-OAuth PendingAuth state depends on an upstream's 401 handshake AND on +// the proxy choosing to defer rather than open a browser. The matrix marks it +// `surface: state-injected` and it is asserted here against the same evaluator +// the served endpoint calls, with the connection snapshot reporting PendingAuth +// (FR-007). +func TestPreflightSabotageMatrixPendingAuthCell(t *testing.T) { + scenarios := loadSabotageMatrix(t) + scenario, ok := scenarios["pending_auth"] + require.True(t, ok, "the pending_auth cell must exist in the matrix") + require.Equal(t, "state-injected", scenario.Surface) + + const server, tool = "oauthy", "sync_issues" + id := server + ":" + tool + + results, err := preflight.Evaluate(context.Background(), preflight.EvalContext{ + Index: stubIndex{tools: map[string][]string{server: {tool}}}, + Approvals: stubApprovals{}, + State: stubState{state: preflight.RuntimeStatePendingAuth}, + Policy: stubPolicy{enabled: map[string]bool{server: true}}, + Tier: preflight.TierOperator, + }, []preflight.ToolRef{{ID: id}}) + require.NoError(t, err) + require.Len(t, results, 1) + + result := results[0] + verdict := preflight.VerdictForResults(results) + assert.Equal(t, scenario.Expect.Status, result.Status) + assert.Equal(t, scenario.Expect.Reason, result.Reason) + require.NotNil(t, scenario.Expect.Retryable) + assert.Equal(t, *scenario.Expect.Retryable, result.Retryable, "waiting cannot resolve a missing login") + assert.Equal(t, scenario.Expect.Action, result.Action) + assert.Equal(t, scenario.Expect.Verdict, verdict) + assert.Equal(t, scenario.Expect.ExitCode, preflight.ExitCode(verdict)) +} + +// --- minimal read-interface stubs (no proxy wiring, no I/O) --- + +type stubIndex struct{ tools map[string][]string } + +func (s stubIndex) ToolsByServer(serverName string) ([]preflight.IndexedTool, error) { + out := make([]preflight.IndexedTool, 0, len(s.tools[serverName])) + for _, name := range s.tools[serverName] { + out = append(out, preflight.IndexedTool{Name: serverName + ":" + name}) + } + return out, nil +} + +func (s stubIndex) IndexedServerNames() ([]string, error) { + names := make([]string, 0, len(s.tools)) + for name := range s.tools { + names = append(names, name) + } + return names, nil +} + +type stubApprovals struct{} + +func (stubApprovals) ToolApproval(_, _ string) (*preflight.ApprovalState, error) { return nil, nil } + +type stubState struct{ state preflight.ServerRuntimeState } + +func (s stubState) ServerRuntime(_ string) (preflight.ServerRuntime, bool) { + return preflight.ServerRuntime{State: s.state}, true +} + +type stubPolicy struct{ enabled map[string]bool } + +func (p stubPolicy) ServerPolicy(serverName string) (preflight.ServerPolicy, error) { + enabled, found := p.enabled[serverName] + return preflight.ServerPolicy{Found: found, Enabled: enabled}, nil +} + +func (stubPolicy) ToolConfigDenied(_, _ string) (bool, error) { return false, nil } +func (stubPolicy) QuarantineEnabled() bool { return true } diff --git a/internal/server/server.go b/internal/server/server.go index 137b4587..027d7db6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -317,14 +317,7 @@ func (s *Server) mcpAuthMiddleware(next http.Handler) http.Handler { } }() - authCtx := &auth.AuthContext{ - Type: auth.AuthTypeAgent, - AgentName: agentToken.Name, - TokenPrefix: agentToken.TokenPrefix, - AllowedServers: agentToken.AllowedServers, - Permissions: agentToken.Permissions, - ProfilePin: agentToken.ProfilePin, - } + authCtx := agentToken.AuthContext() ctx := auth.WithAuthContext(r.Context(), authCtx) next.ServeHTTP(w, r.WithContext(ctx)) return diff --git a/internal/server/testdata/preflight_fixture_server.js b/internal/server/testdata/preflight_fixture_server.js new file mode 100644 index 00000000..91e0932e --- /dev/null +++ b/internal/server/testdata/preflight_fixture_server.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node +// Zero-dependency stdio MCP upstream for the Spec 098 preflight sabotage matrix +// (T026/T027). It exists because every cell of the matrix needs an upstream the +// test can SABOTAGE deterministically, and no third-party server offers that: +// +// FIXTURE_TOOLS_FILE path to a JSON array of tool definitions. It is re-read +// on EVERY tools/list, which is what makes tool-definition +// drift (rug-pull) and post-baseline tool additions +// reproducible: rewrite the file, restart the server, and +// the proxy sees a changed/new definition. +// FIXTURE_INIT_DELAY_MS milliseconds to stall before answering `initialize`, +// which parks the proxy in its connecting/discovering +// state for the server_initializing cell. +// FIXTURE_FAIL_FILE when this path exists AT STARTUP the process exits +// immediately with a non-zero status. Creating the file +// and then killing the child makes the failure STICK +// across the proxy's automatic reconnects, so the +// server_unhealthy cell has a stable state to assert +// instead of a flapping one. +// +// Deliberately hand-rolled: MCP's stdio transport is newline-delimited JSON-RPC, +// so a dependency-free implementation keeps the E2E runnable with nothing but a +// `node` binary — no npm install, no network, no lockfile drift. +'use strict'; + +const fs = require('fs'); + +const toolsFile = process.env.FIXTURE_TOOLS_FILE || ''; +const failFile = process.env.FIXTURE_FAIL_FILE || ''; +const initDelayMs = Number.parseInt(process.env.FIXTURE_INIT_DELAY_MS || '0', 10) || 0; + +if (failFile && fs.existsSync(failFile)) { + process.stderr.write('[preflight-fixture] fail switch present, exiting\n'); + process.exit(1); +} + +function loadTools() { + if (!toolsFile) { + return []; + } + try { + const parsed = JSON.parse(fs.readFileSync(toolsFile, 'utf8')); + return Array.isArray(parsed) ? parsed : []; + } catch (err) { + process.stderr.write(`[preflight-fixture] tools file unreadable: ${err}\n`); + return []; + } +} + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function reply(id, result) { + send({ jsonrpc: '2.0', id, result }); +} + +function handle(message) { + // Notifications carry no id and expect no response. + if (message.id === undefined || message.id === null) { + return; + } + + switch (message.method) { + case 'initialize': { + const version = (message.params && message.params.protocolVersion) || '2024-11-05'; + const answer = () => + reply(message.id, { + protocolVersion: version, + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 'preflight-fixture', version: '1.0.0' }, + }); + if (initDelayMs > 0) { + setTimeout(answer, initDelayMs); + } else { + answer(); + } + break; + } + case 'ping': + reply(message.id, {}); + break; + case 'tools/list': + reply(message.id, { tools: loadTools() }); + break; + case 'tools/call': + reply(message.id, { + content: [ + { + type: 'text', + text: JSON.stringify({ tool: message.params && message.params.name, ok: true }), + }, + ], + }); + break; + case 'resources/list': + reply(message.id, { resources: [] }); + break; + case 'prompts/list': + reply(message.id, { prompts: [] }); + break; + default: + send({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32601, message: `Method not found: ${message.method}` }, + }); + } +} + +let buffer = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buffer += chunk; + let newline = buffer.indexOf('\n'); + while (newline >= 0) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (line) { + try { + handle(JSON.parse(line)); + } catch (err) { + process.stderr.write(`[preflight-fixture] bad frame: ${err}\n`); + } + } + newline = buffer.indexOf('\n'); + } +}); +process.stdin.on('end', () => process.exit(0)); + +process.stderr.write(`[preflight-fixture] ready pid=${process.pid} tools=${toolsFile}\n`); diff --git a/internal/server/testdata/preflight_sabotage_matrix.json b/internal/server/testdata/preflight_sabotage_matrix.json new file mode 100644 index 00000000..f617f374 --- /dev/null +++ b/internal/server/testdata/preflight_sabotage_matrix.json @@ -0,0 +1,290 @@ +{ + "spec": "098-tools-preflight", + "requirement": "FR-016", + "description": "Scenario-keyed sabotage matrix for the required-tools preflight. Each scenario names one deliberately induced proxy state and the EXACT per-tool verdict it must produce: status, reason, retryable and action (an omitted action means the reason carries none). The set-level verdict and CLI exit code are the ones a single-tool request of that shape must report. internal/server/preflight_e2e_test.go drives every scenario against a real mcpproxy binary and asserts these values independently, and a reflection check fails if any code of the 15-code enum has no scenario here.", + "surfaces": { + "e2e": "induced against a running mcpproxy binary with sabotaged fixture upstreams", + "state-injected": "induced by injecting the connection-state snapshot the runtime cannot be forced into deterministically from outside" + }, + "scenarios": [ + { + "scenario": "all_ready", + "surface": "e2e", + "sabotage": "none: indexed, approved tool on an enabled, healthy, trusted server — asserted both unfiltered and under each of the three annotation filters, since a fully annotated tool must survive all of them", + "expect": { + "status": "ready", + "verdict": "ready", + "exit_code": 0 + } + }, + { + "scenario": "quarantine_flip", + "surface": "e2e", + "sabotage": "POST /api/v1/servers/{server}/quarantine on a previously healthy, indexed server", + "expect": { + "status": "unavailable", + "reason": "server_quarantined", + "retryable": false, + "action": "approve", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "tool_definition_drift", + "surface": "e2e", + "sabotage": "rewrite the upstream tool's description in the fixture tools file, then restart the server so the proxy re-lists it (rug-pull guard trips)", + "expect": { + "status": "unavailable", + "reason": "tool_changed", + "retryable": false, + "action": "approve", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "new_tool_after_baseline", + "surface": "e2e", + "sabotage": "add a tool to the fixture tools file after the trusted server's baseline was auto-approved, then restart the server", + "expect": { + "status": "unavailable", + "reason": "tool_pending_approval", + "retryable": false, + "action": "approve", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "tool_blocked_by_user", + "surface": "e2e", + "sabotage": "POST /api/v1/servers/{server}/tools/block for one tool", + "expect": { + "status": "unavailable", + "reason": "tool_blocked_by_user", + "retryable": false, + "action": "enable", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "config_denial", + "surface": "e2e", + "sabotage": "server configured with disabled_tools containing the requested tool", + "expect": { + "status": "unavailable", + "reason": "tool_denied_by_config", + "retryable": false, + "action": "configure", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "server_disable", + "surface": "e2e", + "sabotage": "server configured with enabled:false", + "expect": { + "status": "unavailable", + "reason": "server_disabled", + "retryable": false, + "action": "enable", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "upstream_killed", + "surface": "e2e", + "sabotage": "arm the fixture's fail-switch file, SIGKILL the upstream process, then force a reconnect: the restarted fixture exits immediately, so the server settles in a stable non-connected state", + "expect": { + "status": "unavailable", + "reason": "server_unhealthy", + "retryable": true, + "action": "view_logs", + "verdict": "degraded_retryable", + "exit_code": 10 + } + }, + { + "scenario": "mid_indexing", + "surface": "e2e", + "sabotage": "restart an already-indexed server with a long initialize delay, so it sits in connecting/discovering while its tools remain in the shared index (a never-indexed server would report not_found instead: existence outranks connection state)", + "expect": { + "status": "unavailable", + "reason": "server_initializing", + "retryable": true, + "verdict": "degraded_retryable", + "exit_code": 10 + } + }, + { + "scenario": "missing_annotation_read_only_only", + "surface": "e2e", + "sabotage": "policy.read_only_only against a tool whose upstream definition declares no annotations", + "expect": { + "status": "unavailable", + "reason": "missing_annotation", + "retryable": false, + "action": "configure", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "missing_annotation_exclude_destructive", + "surface": "e2e", + "sabotage": "policy.exclude_destructive against a tool with no destructiveHint and no read-only hint", + "expect": { + "status": "unavailable", + "reason": "missing_annotation", + "retryable": false, + "action": "configure", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "missing_annotation_exclude_open_world", + "surface": "e2e", + "sabotage": "policy.exclude_open_world against a tool with no openWorldHint", + "expect": { + "status": "unavailable", + "reason": "missing_annotation", + "retryable": false, + "action": "configure", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "policy_filtered_read_only_only", + "surface": "e2e", + "sabotage": "policy.read_only_only against a tool explicitly annotated readOnlyHint:false", + "expect": { + "status": "unavailable", + "reason": "policy_filtered", + "retryable": false, + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "policy_filtered_exclude_destructive", + "surface": "e2e", + "sabotage": "policy.exclude_destructive against a tool explicitly annotated destructiveHint:true", + "expect": { + "status": "unavailable", + "reason": "policy_filtered", + "retryable": false, + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "policy_filtered_exclude_open_world", + "surface": "e2e", + "sabotage": "policy.exclude_open_world against a tool explicitly annotated openWorldHint:true", + "expect": { + "status": "unavailable", + "reason": "policy_filtered", + "retryable": false, + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "unknown_tool_id", + "surface": "e2e", + "sabotage": "misspelled tool name on a configured, healthy server", + "expect": { + "status": "unavailable", + "reason": "not_found", + "retryable": false, + "action": "configure", + "verdict": "unknown_ids", + "exit_code": 12 + } + }, + { + "scenario": "unknown_server", + "surface": "e2e", + "sabotage": "tool id naming a server that is not configured at all", + "expect": { + "status": "unavailable", + "reason": "server_not_configured", + "retryable": false, + "action": "configure", + "verdict": "unknown_ids", + "exit_code": 12 + } + }, + { + "scenario": "hash_mismatch", + "surface": "e2e", + "sabotage": "pin_hash at the current schema version whose digest does not match the tool's stored hash", + "expect": { + "status": "unavailable", + "reason": "hash_mismatch", + "retryable": false, + "action": "configure", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "hash_mismatch_schema_version_bump", + "surface": "e2e", + "sabotage": "pin_hash carrying the tool's CURRENT digest under a different hash schema version (simulates a proxy-side hash-algorithm bump)", + "expect": { + "status": "unavailable", + "reason": "hash_mismatch", + "retryable": false, + "action": "configure", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "pending_auth", + "surface": "state-injected", + "sabotage": "connection-state snapshot reports the deferred-OAuth PendingAuth state for the server", + "expect": { + "status": "unavailable", + "reason": "oauth_required", + "retryable": false, + "action": "login", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "profile_out_of_scope_operator", + "surface": "e2e", + "sabotage": "preflight under a profile whose server list excludes the requested tool's server, called with the operator API key", + "expect": { + "status": "unavailable", + "reason": "server_not_in_scope", + "retryable": false, + "action": "configure", + "verdict": "blocked", + "exit_code": 11 + } + }, + { + "scenario": "profile_out_of_scope_agent_token", + "surface": "e2e", + "sabotage": "same out-of-scope request as profile_out_of_scope_operator, called with an agent token whose allowed_servers excludes the server: scope-silence maps it to a plain not_found, byte-indistinguishable from an absent tool", + "expect": { + "status": "unavailable", + "reason": "not_found", + "retryable": false, + "action": "configure", + "verdict": "unknown_ids", + "exit_code": 12 + } + } + ] +} diff --git a/internal/server/testdata/toolslist_goldens/code_execution_mode.json b/internal/server/testdata/toolslist_goldens/code_execution_mode.json new file mode 100644 index 00000000..bab63007 --- /dev/null +++ b/internal/server/testdata/toolslist_goldens/code_execution_mode.json @@ -0,0 +1,293 @@ +{ + "code_execution": { + "annotations": { + "title": "Code Execution (Disabled)", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Code execution is currently disabled. Enable it by setting \"enable_code_execution\": true in your mcpproxy config.", + "inputSchema": { + "properties": { + "code": { + "description": "JavaScript source code to execute.", + "type": "string" + }, + "script": { + "description": "Name of a stored script to execute.", + "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 for MCP servers and tools. Review and manage quarantined servers and tools to prevent Tool Poisoning Attacks (TPAs). 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)", + "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. '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.", + "enum": [ + "list_quarantined", + "inspect_quarantined", + "quarantine_server", + "inspect_tools", + "approve_tool", + "approve_all_tools", + "block_tool", + "block_all_tools", + "enable_tool", + "disable_tool" + ], + "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)", + "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" + }, + "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/default_server.json b/internal/server/testdata/toolslist_goldens/default_server.json new file mode 100644 index 00000000..777607c1 --- /dev/null +++ b/internal/server/testdata/toolslist_goldens/default_server.json @@ -0,0 +1,469 @@ +{ + "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" + }, + "describe_tool": { + "annotations": { + "title": "Describe Tool", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Return full JSON Schema + long description for specific tools found via retrieve_tools. Use when a compact signature is marked lossy ('~') or you need the exact schema before calling.", + "inputSchema": { + "properties": { + "tool_ids": { + "description": "1-5 tool ids in '\u003cserver\u003e:\u003ctool\u003e' format, from retrieve_tools results.", + "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 for MCP servers and tools. Review and manage quarantined servers and tools to prevent Tool Poisoning Attacks (TPAs). 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)", + "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. '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.", + "enum": [ + "list_quarantined", + "inspect_quarantined", + "quarantine_server", + "inspect_tools", + "approve_tool", + "approve_all_tools", + "block_tool", + "block_all_tools", + "enable_tool", + "disable_tool" + ], + "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)", + "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" + }, + "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 new file mode 100644 index 00000000..c68cdd28 --- /dev/null +++ b/internal/server/testdata/toolslist_goldens/retrieve_tools_mode.json @@ -0,0 +1,490 @@ +{ + "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 (Disabled)", + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "description": "Code execution is currently disabled. Enable it by setting \"enable_code_execution\": true in your mcpproxy config.", + "inputSchema": { + "properties": { + "code": { + "description": "JavaScript source code to execute.", + "type": "string" + }, + "script": { + "description": "Name of a stored script to execute.", + "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 specific tools found via retrieve_tools. Use when a compact signature is marked lossy ('~') or you need the exact schema before calling.", + "inputSchema": { + "properties": { + "tool_ids": { + "description": "1-5 tool ids in '\u003cserver\u003e:\u003ctool\u003e' format, from retrieve_tools results.", + "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 for MCP servers and tools. Review and manage quarantined servers and tools to prevent Tool Poisoning Attacks (TPAs). 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)", + "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. '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.", + "enum": [ + "list_quarantined", + "inspect_quarantined", + "quarantine_server", + "inspect_tools", + "approve_tool", + "approve_all_tools", + "block_tool", + "block_all_tools", + "enable_tool", + "disable_tool" + ], + "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)", + "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" + }, + "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/tool_gate.go b/internal/server/tool_gate.go new file mode 100644 index 00000000..eb7cb612 --- /dev/null +++ b/internal/server/tool_gate.go @@ -0,0 +1,142 @@ +package server + +import ( + "errors" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" +) + +// toolGate is ONE evaluation of the shared per-tool policy gates, consumed by +// every dispatch path (Spec 098 FR-002, plan decision 2): +// +// call_tool_* variants handleCallToolVariant +// legacy call_tool handleCallTool +// direct mode directCallabilityEvaluator +// code_execution + upstreamToolCaller.CallTool (the sandbox's bridge, +// stored scripts (097) shared by both script surfaces) +// +// The refusal DECISION always comes from class (preflight.ClassifyTool), so +// preflight and dispatch cannot disagree about whether a tool is callable. The +// extra fields exist so each path can keep the exact response it always +// produced: lockStatus preserves the dispatch-order preference for the +// pending/changed message over the generic "blocked" one, and configDenied +// selects the operator-policy wording. +type toolGate struct { + serverName string + toolName string + + // class is the shared classification — the single authority on callability. + class preflight.ToolClass + // serverConfig is the stored upstream record, nil when the server is not + // configured (or unreadable). + serverConfig *config.ServerConfig + // approval is the spec 032 record, nil when none exists (implicit-approved). + approval *storage.ToolApprovalRecord + // configDenied is the enabled_tools/disabled_tools verdict. + configDenied bool + // lockStatus is the tool-level quarantine lock ("" | pending | changed) as + // the DISPATCH paths compute it: it reflects only the quarantine gate, so a + // tool that is both user-disabled and pending still reports its lock exactly + // as before this consolidation. class, which follows the spec-098 precedence + // (user block outranks the quarantine lock), remains the callability truth. + lockStatus string + // storageErr is a genuine approval-read failure. Dispatch fails CLOSED on it + // (isToolCallable always has), so it is folded into callable(). + storageErr error +} + +// callable reports whether dispatch may proceed. +func (g toolGate) callable() bool { + return g.class.Callable() && g.storageErr == nil +} + +// serverQuarantined reports the server-level quarantine gate, which every +// dispatch path answers with the quarantine analysis response rather than a +// plain refusal. +func (g toolGate) serverQuarantined() bool { + return g.serverConfig != nil && g.serverConfig.Quarantined +} + +// blockedMessage is the agent-actionable refusal text for a non-callable tool +// that is neither quarantined nor approval-locked. +func (g toolGate) blockedMessage() string { + return blockedToolMessageFor(g.configDenied) +} + +// evaluateToolGate reads the local policy state for one tool exactly once and +// classifies it through the shared classifier. +// +// It reads the LIVE config (currentConfig) for the global quarantine switch, so +// a hot-reloaded quarantine_enabled takes effect on the next call and the +// preflight glue — which reads the same live config — cannot drift from it. In +// unit tests, where no runtime is wired, currentConfig() is the construction +// config, so behavior is unchanged there. +func (p *MCPProxyServer) evaluateToolGate(serverName, toolName string) toolGate { + serverName, toolName = normalizeServerTool(serverName, toolName) + gate := toolGate{serverName: serverName, toolName: toolName} + + if serverName == "" || toolName == "" { + gate.class = preflight.ToolClassServerNotConfigured + return gate + } + + serverConfig, err := p.storage.GetUpstreamServer(serverName) + if err != nil || serverConfig == nil { + // The storage seam reports "no such upstream" as an error; every + // dispatch path has always treated that as not-callable. + gate.class = preflight.ToolClassServerNotConfigured + return gate + } + gate.serverConfig = serverConfig + gate.configDenied = p.isToolConfigDenied(serverName, toolName, serverConfig) + + approval, approvalErr := p.storage.GetToolApproval(serverName, toolName) + switch { + case approvalErr == nil: + gate.approval = approval + case errors.Is(approvalErr, storage.ErrToolApprovalNotFound): + // No record → implicit-approved default. + default: + // A real BBolt failure must not silently re-enable a tool the user + // disabled (isToolCallable's long-standing fail-closed rule). + gate.storageErr = approvalErr + } + + cfg := p.currentConfig() + quarantineEnabled := cfg == nil || cfg.IsQuarantineEnabled() + quarantineGate := quarantineEnabled && !serverConfig.IsQuarantineSkipped() + if quarantineGate && gate.approval != nil { + switch gate.approval.Status { + case storage.ToolApprovalStatusPending, storage.ToolApprovalStatusChanged: + gate.lockStatus = gate.approval.Status + } + } + + gate.class = preflight.ClassifyTool(preflight.ClassifyInputs{ + Server: preflight.ServerPolicy{ + Found: true, + Enabled: serverConfig.Enabled, + Quarantined: serverConfig.Quarantined, + AutoApproveToolChanges: serverConfig.IsQuarantineSkipped(), + }, + QuarantineEnabled: quarantineEnabled, + ConfigDenied: gate.configDenied, + Approval: approvalStateFor(gate.approval), + }) + return gate +} + +// approvalStateFor narrows a storage record to the classifier's read-only view. +func approvalStateFor(record *storage.ToolApprovalRecord) *preflight.ApprovalState { + if record == nil { + return nil + } + return &preflight.ApprovalState{ + Status: record.Status, + Disabled: record.Disabled, + CurrentHash: record.CurrentHash, + HashSchemaVersion: record.HashSchemaVersion, + } +} diff --git a/internal/server/toolslist_snapshot_test.go b/internal/server/toolslist_snapshot_test.go new file mode 100644 index 00000000..2e938cdd --- /dev/null +++ b/internal/server/toolslist_snapshot_test.go @@ -0,0 +1,185 @@ +package server + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Spec 098 (required-tools preflight) T002/T024 — FR-015: the preflight +// feature adds a REST endpoint and a CLI command; it MUST NOT move the MCP +// surface. `tools/list` payloads have to stay byte-identical to the merge-base +// release across every routing mode an agent can be served. +// +// The goldens in testdata/toolslist_goldens/ were captured from the merge-base +// commit (bfd43e7ce, origin/main, pre-098) with this exact test file — copied +// into a throwaway `git worktree` of origin/main and run with +// MCPPROXY_WRITE_TOOLSLIST_GOLDENS set — so the capture and the comparison +// share one serializer and cannot drift. +// +// Unlike the spec-085/094 delta test in mcp_menu_surface_test.go (which allows +// an enumerated, intentional delta), this test allows NO delta at all. A +// failure here is a spec-098 regression, not a golden to refresh: goldens are +// only regenerated when a DIFFERENT, deliberate spec changes the MCP surface. +// +// Surfaces covered (the three routing modes that expose a static, built-in +// tool set): +// +// - default_server — the default /mcp server (`proxy.server`) +// - retrieve_tools_mode — buildCallToolModeTools() (/mcp/call, /mcp/p/) +// - code_execution_mode — buildCodeExecModeTools() +// +// Direct mode is deliberately excluded: its tools/list is a projection of live +// upstream catalogs (buildDirectModeTools → upstreamManager.DiscoverTools), so +// it has no static payload to snapshot. Direct-mode non-regression is covered +// by the dispatch-parity tests instead. + +const ( + toolsListGoldenDir = "toolslist_goldens" + + // toolsListGoldenWriteEnv, when set to a directory, makes this test WRITE + // the goldens instead of comparing them. Used once to capture the + // merge-base surface from a detached worktree of origin/main. Never set it + // to "fix" a failing run — see the doc comment above. + toolsListGoldenWriteEnv = "MCPPROXY_WRITE_TOOLSLIST_GOLDENS" +) + +// toolsListGoldenSurfaces is the surface name -> golden file basename map. +var toolsListGoldenSurfaces = []string{ + "default_server", + "retrieve_tools_mode", + "code_execution_mode", +} + +// captureToolsListSurfaces serializes each routing mode's registered tool +// schemas exactly as an agent receives them from tools/list: name -> marshaled +// mcp.Tool (description, annotations, inputSchema, outputSchema, …). +func captureToolsListSurfaces(t *testing.T, proxy *MCPProxyServer) map[string]map[string]json.RawMessage { + t.Helper() + + surfaces := map[string]map[string]json.RawMessage{ + "default_server": {}, + "retrieve_tools_mode": {}, + "code_execution_mode": {}, + } + + for name, st := range proxy.server.ListTools() { + raw, err := json.Marshal(st.Tool) + require.NoError(t, err, "marshal default-server tool %q", name) + surfaces["default_server"][name] = raw + } + for _, st := range proxy.buildCallToolModeTools() { + raw, err := json.Marshal(st.Tool) + require.NoError(t, err, "marshal retrieve_tools-mode tool %q", st.Tool.Name) + surfaces["retrieve_tools_mode"][st.Tool.Name] = raw + } + for _, st := range proxy.buildCodeExecModeTools() { + raw, err := json.Marshal(st.Tool) + require.NoError(t, err, "marshal code_execution-mode tool %q", st.Tool.Name) + surfaces["code_execution_mode"][st.Tool.Name] = raw + } + + for _, surface := range toolsListGoldenSurfaces { + require.NotEmpty(t, surfaces[surface], "surface %s registered no tools — the snapshot would be vacuous", surface) + } + return surfaces +} + +// renderToolsListGolden produces the canonical golden bytes for one surface: +// MarshalIndent over a map (encoding/json sorts map keys and re-indents the +// embedded RawMessages), plus a trailing newline so the files are diffable. +func renderToolsListGolden(t *testing.T, tools map[string]json.RawMessage) []byte { + t.Helper() + raw, err := json.MarshalIndent(tools, "", " ") + require.NoError(t, err) + return append(raw, '\n') +} + +func toolsListGoldenPath(surface string) string { + return filepath.Join("testdata", toolsListGoldenDir, surface+".json") +} + +// TestToolsListSnapshot_MatchesMergeBaseGoldens is the FR-015 gate. +func TestToolsListSnapshot_MatchesMergeBaseGoldens(t *testing.T) { + proxy := createTestMCPProxyServer(t) + surfaces := captureToolsListSurfaces(t, proxy) + + if outDir := os.Getenv(toolsListGoldenWriteEnv); outDir != "" { + require.NoError(t, os.MkdirAll(outDir, 0o755)) + for _, surface := range toolsListGoldenSurfaces { + path := filepath.Join(outDir, surface+".json") + require.NoError(t, os.WriteFile(path, renderToolsListGolden(t, surfaces[surface]), 0o644)) + t.Logf("wrote golden %s (%d tools)", path, len(surfaces[surface])) + } + t.Skipf("goldens written to %s (%s set); comparison skipped", outDir, toolsListGoldenWriteEnv) + } + + for _, surface := range toolsListGoldenSurfaces { + surface := surface + t.Run(surface, func(t *testing.T) { + want, err := os.ReadFile(toolsListGoldenPath(surface)) + require.NoError(t, err, "missing golden for surface %s", surface) + + got := renderToolsListGolden(t, surfaces[surface]) + if bytes.Equal(got, want) { + return + } + // Byte comparison failed: report the per-tool diff so the + // regression is readable instead of a wall of JSON. + reportToolsListDiff(t, surface, want, got) + t.Errorf("surface %s: tools/list is not byte-identical to the merge-base golden (spec 098 FR-015)", surface) + }) + } +} + +// reportToolsListDiff decodes both sides and reports added/removed/changed +// tools individually. +func reportToolsListDiff(t *testing.T, surface string, want, got []byte) { + t.Helper() + + var wantTools, gotTools map[string]json.RawMessage + if err := json.Unmarshal(want, &wantTools); err != nil { + t.Errorf("surface %s: golden is not valid JSON: %v", surface, err) + return + } + if err := json.Unmarshal(got, &gotTools); err != nil { + t.Errorf("surface %s: current surface is not valid JSON: %v", surface, err) + return + } + + var added, removed []string + for name := range gotTools { + if _, ok := wantTools[name]; !ok { + added = append(added, name) + } + } + for name := range wantTools { + if _, ok := gotTools[name]; !ok { + removed = append(removed, name) + } + } + sort.Strings(added) + sort.Strings(removed) + assert.Empty(t, added, "surface %s: tools added to the MCP surface (FR-015 forbids any change)", surface) + assert.Empty(t, removed, "surface %s: tools removed from the MCP surface (FR-015 forbids any change)", surface) + + names := make([]string, 0, len(wantTools)) + for name := range wantTools { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + gotTool, ok := gotTools[name] + if !ok { + continue // already reported as removed + } + assert.JSONEq(t, string(wantTools[name]), string(gotTool), + "surface %s: tool %q schema changed (FR-015)", surface, name) + } +} diff --git a/internal/storage/activity_models.go b/internal/storage/activity_models.go index b31ad85a..e14ddc06 100644 --- a/internal/storage/activity_models.go +++ b/internal/storage/activity_models.go @@ -37,6 +37,15 @@ const ( // event: acquisition, refresh, injection, or connect (Spec 074 T10). It // carries attribution (UserID, ServerName) and never any token/secret value. ActivityTypeCredentialBroker ActivityType = "credential_broker" + // ActivityTypePreflight represents one executed required-tools preflight + // (Spec 098 FR-014). The record is written SYNCHRONOUSLY before the + // preflight is answered — see runtime.ActivityService.RecordPreflight — so a + // caller that got a verdict can always find the run that produced it. + // + // A preflight is set-scoped, not server-scoped: ServerName and ToolName stay + // empty and the per-tool detail lives in Metadata under the MetadataKeyPreflight* + // keys. RequestID is the correlation handle (`activity list --request-id`). + ActivityTypePreflight ActivityType = "preflight" ) // ValidActivityTypes is the list of all valid activity types for filtering (Spec 024) @@ -52,6 +61,7 @@ var ValidActivityTypes = []string{ string(ActivityTypeToolQuarantineChange), string(ActivityTypeSecurityScan), string(ActivityTypeCredentialBroker), + string(ActivityTypePreflight), } // Activity status vocabulary. Activity status is a CLOSED vocabulary: every @@ -95,6 +105,28 @@ const ( MetadataKeyRejectionRetryAfterMs = "rejection_retry_after_ms" ) +// Metadata keys carried by an ActivityTypePreflight record (spec 098 FR-014, +// data-model.md "Activity record"). The payload is deliberately small and +// enum-only: reason CODES and counts, never tool descriptions or arguments. +const ( + // MetadataKeyPreflightVerdict is the set-level verdict + // (ready|degraded_retryable|blocked|unknown_ids). + MetadataKeyPreflightVerdict = "verdict" + // MetadataKeyPreflightIDsCount is the number of unique tool ids evaluated. + MetadataKeyPreflightIDsCount = "ids_count" + // MetadataKeyPreflightReasons is a {reason_code: count} rollup over the + // unavailable results — the shape a dashboard or CLI summary reads. + MetadataKeyPreflightReasons = "reasons" + // MetadataKeyPreflightPerTool is the ordered per-tool detail: + // [{id, status, reason?}] using the PreflightPerTool* keys below. + MetadataKeyPreflightPerTool = "per_tool" + + // Keys inside one MetadataKeyPreflightPerTool entry. + PreflightPerToolKeyID = "id" + PreflightPerToolKeyStatus = "status" + PreflightPerToolKeyReason = "reason" +) + // ActivitySource indicates how the activity was triggered type ActivitySource string diff --git a/internal/storage/activity_models_preflight_test.go b/internal/storage/activity_models_preflight_test.go new file mode 100644 index 00000000..1f6b9891 --- /dev/null +++ b/internal/storage/activity_models_preflight_test.go @@ -0,0 +1,105 @@ +package storage + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The preflight record kind must be filterable like every other kind: activity +// filtering compares the Type field against the ValidActivityTypes allowlist the +// CLI and REST surfaces validate against, so a type that is not in the list is +// storable but not queryable (spec 098 FR-014 / US3). +func TestPreflightActivityTypeIsInAllowlist(t *testing.T) { + assert.Contains(t, ValidActivityTypes, string(ActivityTypePreflight)) + assert.Equal(t, ActivityType("preflight"), ActivityTypePreflight) +} + +func TestActivityFilterMatchesPreflightType(t *testing.T) { + record := &ActivityRecord{ + Type: ActivityTypePreflight, + Status: ActivityStatusBlocked, + RequestID: "req-098", + Timestamp: time.Now(), + } + + filter := DefaultActivityFilter() + filter.Types = []string{string(ActivityTypePreflight)} + assert.True(t, filter.Matches(record)) + + // The request id is the documented correlation handle + // (`activity list --request-id `), so it must filter as a first-class + // field rather than out of Metadata. + byRequest := DefaultActivityFilter() + byRequest.RequestID = "req-098" + assert.True(t, byRequest.Matches(record)) + + byOtherRequest := DefaultActivityFilter() + byOtherRequest.RequestID = "req-other" + assert.False(t, byOtherRequest.Matches(record)) + + byOtherType := DefaultActivityFilter() + byOtherType.Types = []string{string(ActivityTypeToolCall)} + assert.False(t, byOtherType.Matches(record)) +} + +// The metadata payload is JSON round-tripped through BBolt, so the documented +// shape has to survive marshal/unmarshal with its nested map and slice intact. +func TestPreflightMetadataRoundTrip(t *testing.T) { + original := &ActivityRecord{ + ID: "01J000000000000000000000", + Type: ActivityTypePreflight, + Source: ActivitySourceCLI, + Status: ActivityStatusBlocked, + RequestID: "req-098", + Timestamp: time.Now().UTC().Truncate(time.Second), + Metadata: map[string]interface{}{ + MetadataKeyPreflightVerdict: "blocked", + MetadataKeyPreflightIDsCount: 2, + MetadataKeyPreflightReasons: map[string]interface{}{"server_quarantined": 1}, + MetadataKeyPreflightPerTool: []interface{}{ + map[string]interface{}{ + PreflightPerToolKeyID: "gh:create_issue", + PreflightPerToolKeyStatus: "ready", + }, + map[string]interface{}{ + PreflightPerToolKeyID: "slack:post", + PreflightPerToolKeyStatus: "unavailable", + PreflightPerToolKeyReason: "server_quarantined", + }, + }, + }, + } + + blob, err := original.MarshalBinary() + require.NoError(t, err) + + var decoded ActivityRecord + require.NoError(t, decoded.UnmarshalBinary(blob)) + + assert.Equal(t, ActivityTypePreflight, decoded.Type) + assert.Equal(t, "req-098", decoded.RequestID) + assert.Equal(t, "blocked", decoded.Metadata[MetadataKeyPreflightVerdict]) + assert.InDelta(t, 2, decoded.Metadata[MetadataKeyPreflightIDsCount], 0.0001) + + reasons, ok := decoded.Metadata[MetadataKeyPreflightReasons].(map[string]interface{}) + require.True(t, ok) + assert.InDelta(t, 1, reasons["server_quarantined"], 0.0001) + + perTool, ok := decoded.Metadata[MetadataKeyPreflightPerTool].([]interface{}) + require.True(t, ok) + require.Len(t, perTool, 2) + second, ok := perTool[1].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "slack:post", second[PreflightPerToolKeyID]) + assert.Equal(t, "unavailable", second[PreflightPerToolKeyStatus]) + assert.Equal(t, "server_quarantined", second[PreflightPerToolKeyReason]) + + // A ready entry omits the reason key entirely. + first, ok := perTool[0].(map[string]interface{}) + require.True(t, ok) + _, hasReason := first[PreflightPerToolKeyReason] + assert.False(t, hasReason) +} diff --git a/test/e2e-config.json b/test/e2e-config.json index 0e78aa54..8f59c7da 100644 --- a/test/e2e-config.json +++ b/test/e2e-config.json @@ -5,6 +5,7 @@ "debug_search": false, "mcpServers": [ { + "quarantined": false, "name": "everything", "protocol": "stdio", "command": "npx", @@ -14,11 +15,11 @@ ], "oauth": null, "enabled": true, - "quarantined": false, "created": "2025-01-01T00:00:00Z", - "updated": "2026-07-14T21:23:24.883151+03:00" + "updated": "2026-08-15T20:42:15.386597+03:00" }, { + "quarantined": false, "name": "launcher-test", "url": "http://127.0.0.1:39933/mcp", "protocol": "http", @@ -30,9 +31,8 @@ ], "oauth": null, "enabled": true, - "quarantined": false, "created": "2026-05-11T00:00:00Z", - "updated": "2026-07-14T21:23:24.892192+03:00", + "updated": "2026-08-15T20:42:15.394634+03:00", "launcher_wait_timeout": "10s" } ], @@ -69,7 +69,7 @@ "compress": true, "json_format": false }, - "api_key": "a2f179cd162108e56b5f2493c309993d17e313626ee3ed1f8360d6fb60d3830b", + "api_key": "b255c7a2740f579778244f9fc0c871e5f5af02b46382dbfd47476563e3bd9958", "require_mcp_auth": false, "read_only_mode": false, "disable_management": false, @@ -155,6 +155,7 @@ "enable_code_execution": false, "code_execution_timeout_ms": 120000, "code_execution_pool_size": 10, + "code_execution_max_parallel": 8, "activity_retention_days": 90, "activity_max_records": 100000, "activity_max_size_mb": 256, From 224713a6592ae3e1ff80da4f88a00da207ba9812 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 15 Aug 2026 20:47:00 +0300 Subject: [PATCH 5/9] feat(preflight): REST endpoint, CLI with typed exit codes, activity browsability (098) Related #969 POST /api/v1/preflight (APIResponse envelope, tier detection, wait_ms poll with 4-slot semaphore degrade, durable-record-before-200 else 503). mcpproxy tools preflight with exit codes 0/10/11/12 (worst class wins). Operator-tier hash-pin authoring on tool listings (never to tokens). Activity type preflight across CLI/REST/frontend. Swagger + contracts.ts regen; serveredition staticcheck fixes (pre-existing). --- cmd/mcpproxy/activity_cmd.go | 241 +++++- cmd/mcpproxy/activity_preflight_test.go | 246 +++++++ cmd/mcpproxy/exit_codes.go | 59 ++ cmd/mcpproxy/main.go | 10 + cmd/mcpproxy/preflight_cmd_test.go | 375 ++++++++++ cmd/mcpproxy/tools_cmd.go | 337 +++++++++ cmd/mcpproxy/tools_hash_pin_test.go | 82 +++ frontend/src/components/ActivityWidget.vue | 12 +- frontend/src/types/api.ts | 7 + frontend/src/types/contracts.ts | 83 +++ frontend/src/utils/activity.ts | 155 +++- frontend/src/views/Activity.vue | 93 ++- .../tests/unit/activity-preflight.spec.ts | 138 ++++ internal/cliclient/client.go | 54 ++ internal/httpapi/activity.go | 2 +- internal/httpapi/auth_profile_pin_test.go | 99 +++ internal/httpapi/contracts_test.go | 5 + internal/httpapi/preflight.go | 399 ++++++++++ internal/httpapi/preflight_bench_test.go | 216 ++++++ internal/httpapi/preflight_test.go | 685 ++++++++++++++++++ internal/httpapi/security_test.go | 5 + internal/httpapi/server.go | 74 +- internal/httpapi/tool_hash_disclosure_test.go | 197 +++++ .../serveredition/api/user_activity_test.go | 4 +- .../serveredition/auth/jwt_tokens_test.go | 2 +- oas/docs.go | 4 +- oas/swagger.yaml | 197 +++++ 27 files changed, 3749 insertions(+), 32 deletions(-) create mode 100644 cmd/mcpproxy/activity_preflight_test.go create mode 100644 cmd/mcpproxy/preflight_cmd_test.go create mode 100644 cmd/mcpproxy/tools_hash_pin_test.go create mode 100644 frontend/tests/unit/activity-preflight.spec.ts create mode 100644 internal/httpapi/auth_profile_pin_test.go create mode 100644 internal/httpapi/preflight.go create mode 100644 internal/httpapi/preflight_bench_test.go create mode 100644 internal/httpapi/preflight_test.go create mode 100644 internal/httpapi/tool_hash_disclosure_test.go diff --git a/cmd/mcpproxy/activity_cmd.go b/cmd/mcpproxy/activity_cmd.go index ed30dacf..8afa879b 100644 --- a/cmd/mcpproxy/activity_cmd.go +++ b/cmd/mcpproxy/activity_cmd.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "os/signal" + "sort" "strings" "syscall" "time" @@ -23,6 +24,7 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" "github.com/smart-mcp-proxy/mcpproxy-go/internal/logs" "github.com/smart-mcp-proxy/mcpproxy-go/internal/socket" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" ) // Activity command flags @@ -85,6 +87,7 @@ func (f *ActivityFilter) Validate() error { validTypes := []string{ "tool_call", "policy_decision", "quarantine_change", "server_change", "system_start", "system_stop", "internal_tool_call", "config_change", // Spec 024: new types + string(storage.ActivityTypePreflight), // Spec 098: required-tools preflight } // Split by comma for multi-type support types := strings.Split(f.Type, ",") @@ -556,6 +559,219 @@ func displaySensitiveDataSection(activity map[string]interface{}) { } } +// --- Spec 098: preflight activity records ------------------------------------ +// +// A preflight record is set-scoped, not server-scoped: server_name and +// tool_name are empty and everything an operator wants to see lives in +// Metadata ({verdict, ids_count, reasons{code:count}, per_tool[{id,status, +// reason?}]}, written by runtime.ActivityService.RecordPreflight). Without the +// renderers below, `activity list` shows a bare row with three empty columns +// and `activity show` shows nothing at all — the FR-014 transparency promise +// only holds if the record is actually readable. +// +// The metadata arrives here as decoded JSON, so counts are float64 and the +// nested payloads are []interface{} / map[string]interface{}; every accessor +// below tolerates both that and the native Go shape used in tests. + +// maxPreflightSummaryReasons caps how many distinct reason codes the one-line +// summary names before it collapses the tail into "+N more". A preflight may +// carry up to 100 ids across 15 reason codes; an uncapped rollup would push +// every other column of `activity list` off screen. +const maxPreflightSummaryReasons = 3 + +// maxPreflightSummaryCell bounds the summary in the `activity list` TOOL +// column, matching the cap the tool tables already use for their widest cell. +const maxPreflightSummaryCell = 60 + +// isPreflightActivity reports whether a record is a Spec 098 preflight. +func isPreflightActivity(activity map[string]interface{}) bool { + return getStringField(activity, "type") == string(storage.ActivityTypePreflight) +} + +// preflightReasonCount is one {reason code, count} pair of the metadata rollup. +type preflightReasonCount struct { + Reason string + Count int +} + +// preflightReasonRollup reads metadata["reasons"] into a DETERMINISTIC order: +// most frequent first, ties broken alphabetically. Map iteration order would +// otherwise make the same record render differently on every invocation, which +// breaks both diffing two runs and any test that asserts the line. +func preflightReasonRollup(metadata map[string]interface{}) []preflightReasonCount { + counts := map[string]int{} + for reason, raw := range getMapField(metadata, storage.MetadataKeyPreflightReasons) { + if count, ok := numericMetadataValue(raw); ok { + counts[reason] = count + } + } + + // Fallback for a record whose rollup is missing: recount from the per-tool + // detail, which carries the same codes. + if len(counts) == 0 { + for _, entry := range getArrayField(metadata, storage.MetadataKeyPreflightPerTool) { + tool, ok := entry.(map[string]interface{}) + if !ok { + continue + } + if reason := getStringField(tool, storage.PreflightPerToolKeyReason); reason != "" { + counts[reason]++ + } + } + } + if len(counts) == 0 { + return nil + } + + rollup := make([]preflightReasonCount, 0, len(counts)) + for reason, count := range counts { + rollup = append(rollup, preflightReasonCount{Reason: reason, Count: count}) + } + sort.Slice(rollup, func(i, j int) bool { + if rollup[i].Count != rollup[j].Count { + return rollup[i].Count > rollup[j].Count + } + return rollup[i].Reason < rollup[j].Reason + }) + return rollup +} + +// formatPreflightReasons renders a rollup as "code xN, code xN". limit <= 0 +// means "name them all"; a positive limit collapses the tail into "+N more". +func formatPreflightReasons(rollup []preflightReasonCount, limit int) string { + if len(rollup) == 0 { + return "" + } + + shown := rollup + remaining := 0 + if limit > 0 && len(rollup) > limit { + shown = rollup[:limit] + remaining = len(rollup) - limit + } + + parts := make([]string, 0, len(shown)+1) + for _, entry := range shown { + parts = append(parts, fmt.Sprintf("%s x%d", entry.Reason, entry.Count)) + } + if remaining > 0 { + parts = append(parts, fmt.Sprintf("+%d more", remaining)) + } + return strings.Join(parts, ", ") +} + +// numericMetadataValue reads a metadata count that may have arrived as JSON +// (float64) or as the native int the writer used. +func numericMetadataValue(raw interface{}) (int, bool) { + switch v := raw.(type) { + case float64: + return int(v), true + case int: + return v, true + default: + return 0, false + } +} + +// preflightIDsCount is how many unique tool ids the run evaluated. ids_count is +// authoritative (the writer sets it); per_tool length is the fallback for a +// record written by an older/partial writer. +func preflightIDsCount(metadata map[string]interface{}) int { + if count := getIntField(metadata, storage.MetadataKeyPreflightIDsCount); count > 0 { + return count + } + return len(getArrayField(metadata, storage.MetadataKeyPreflightPerTool)) +} + +// preflightActivitySummary renders the one-line verdict summary shown in the +// `activity list` table, e.g. "blocked (4 tools): server_disabled x2, +// tool_changed x1". Empty string for anything that is not a readable preflight +// record, so the caller falls back to its usual "-" placeholder. +func preflightActivitySummary(activity map[string]interface{}) string { + if !isPreflightActivity(activity) { + return "" + } + metadata := getMapField(activity, "metadata") + if metadata == nil { + return "" + } + verdict := getStringField(metadata, storage.MetadataKeyPreflightVerdict) + if verdict == "" { + return "" + } + + count := preflightIDsCount(metadata) + unit := "tools" + if count == 1 { + unit = "tool" + } + summary := fmt.Sprintf("%s (%d %s)", verdict, count, unit) + + if reasons := formatPreflightReasons(preflightReasonRollup(metadata), maxPreflightSummaryReasons); reasons != "" { + summary += ": " + reasons + } + return summary +} + +// preflightDetailLines builds the `activity show` section for a preflight +// record. It returns lines instead of printing so the rendering is unit-tested +// without capturing stdout. Empty slice ⇒ nothing to render. +func preflightDetailLines(activity map[string]interface{}) []string { + if !isPreflightActivity(activity) { + return nil + } + metadata := getMapField(activity, "metadata") + if metadata == nil { + return nil + } + verdict := getStringField(metadata, storage.MetadataKeyPreflightVerdict) + if verdict == "" { + return nil + } + + lines := []string{ + "", + "Preflight:", + fmt.Sprintf(" Verdict: %s", verdict), + fmt.Sprintf(" Tools Checked: %d", preflightIDsCount(metadata)), + } + // The full rollup here — the detail view has the room the table row lacks. + if reasons := formatPreflightReasons(preflightReasonRollup(metadata), 0); reasons != "" { + lines = append(lines, fmt.Sprintf(" Reasons: %s", reasons)) + } + + perTool := getArrayField(metadata, storage.MetadataKeyPreflightPerTool) + if len(perTool) == 0 { + return lines + } + + lines = append(lines, "", " Tools:") + for i, entry := range perTool { + tool, ok := entry.(map[string]interface{}) + if !ok { + continue + } + // Tool ids are caller-supplied strings that round-trip through the + // activity log; escape them before they reach a tty (same trust + // boundary as `tools list`). + id := sanitizeName(getStringField(tool, storage.PreflightPerToolKeyID)) + status := getStringField(tool, storage.PreflightPerToolKeyStatus) + line := fmt.Sprintf(" [%d] %-40s %s", i+1, id, status) + if reason := getStringField(tool, storage.PreflightPerToolKeyReason); reason != "" { + line += " " + reason + } + lines = append(lines, line) + } + return lines +} + +// displayPreflightSection prints the preflight detail for `activity show`. +func displayPreflightSection(activity map[string]interface{}) { + for _, line := range preflightDetailLines(activity) { + fmt.Println(line) + } +} + // formatSeverityWithColor returns a severity string with visual indicator func formatSeverityWithColor(severity string) string { if activityNoIcons { @@ -734,7 +950,7 @@ func init() { activityCmd.AddCommand(activityExportCmd) // List command flags - activityListCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type (comma-separated for multiple): tool_call, system_start, system_stop, internal_tool_call, config_change, policy_decision, quarantine_change, server_change") + activityListCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type (comma-separated for multiple): tool_call, system_start, system_stop, internal_tool_call, config_change, policy_decision, quarantine_change, server_change, preflight") activityListCmd.Flags().StringVarP(&activityServer, "server", "s", "", "Filter by server name") activityListCmd.Flags().StringVar(&activityTool, "tool", "", "Filter by tool name") activityListCmd.Flags().StringVar(&activityStatus, "status", "", "Filter by status: success, error, blocked, rejected") @@ -755,7 +971,7 @@ func init() { activityListCmd.Flags().StringVar(&activityAuthType, "auth-type", "", "Filter by auth type: admin, agent") // Watch command flags - activityWatchCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type (comma-separated): tool_call, system_start, system_stop, internal_tool_call, config_change, policy_decision, quarantine_change, server_change") + activityWatchCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type (comma-separated): tool_call, system_start, system_stop, internal_tool_call, config_change, policy_decision, quarantine_change, server_change, preflight") activityWatchCmd.Flags().StringVarP(&activityServer, "server", "s", "", "Filter by server name") // Show command flags @@ -771,7 +987,7 @@ func init() { activityExportCmd.Flags().StringVarP(&activityExportFormat, "format", "f", "json", "Export format: json, csv") activityExportCmd.Flags().BoolVar(&activityIncludeBodies, "include-bodies", false, "Include full request/response bodies") // Reuse list filter flags for export - activityExportCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type (comma-separated): tool_call, system_start, system_stop, internal_tool_call, config_change, policy_decision, quarantine_change, server_change") + activityExportCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type (comma-separated): tool_call, system_start, system_stop, internal_tool_call, config_change, policy_decision, quarantine_change, server_change, preflight") activityExportCmd.Flags().StringVarP(&activityServer, "server", "s", "", "Filter by server name") activityExportCmd.Flags().StringVar(&activityTool, "tool", "", "Filter by tool name") activityExportCmd.Flags().StringVar(&activityStatus, "status", "", "Filter by status: success, error, blocked, rejected") @@ -896,6 +1112,15 @@ func runActivityList(cmd *cobra.Command, _ []string) error { durationMs := getIntField(act, "duration_ms") timestamp := getStringField(act, "timestamp") + // Spec 098: a preflight is set-scoped — server_name/tool_name are empty + // by construction, so the TOOL cell carries the verdict summary instead + // of rendering an empty row the operator cannot interpret. + if tool == "" { + if summary := preflightActivitySummary(act); summary != "" { + tool = sanitizeCell(summary, maxPreflightSummaryCell) + } + } + // Extract intent from metadata (Spec 018) intentStr := formatIntentIndicator(act) @@ -1392,6 +1617,13 @@ func runActivityShow(cmd *cobra.Command, args []string) error { fmt.Printf("Session ID: %s\n", sessionID) } + // Spec 098 (SC-005): the request id is how a preflight record is joined to + // the tool calls of the same workflow (`activity list --request-id `), + // so the detail view has to show it, not just accept it as a filter. + if requestID := getStringField(activity, "request_id"); requestID != "" { + fmt.Printf("Request ID: %s\n", requestID) + } + if errMsg := getStringField(activity, "error_message"); errMsg != "" { fmt.Printf("Error: %s\n", errMsg) } @@ -1402,6 +1634,9 @@ func runActivityShow(cmd *cobra.Command, args []string) error { // Sensitive Data Detection (Spec 026) displaySensitiveDataSection(activity) + // Preflight verdict + per-tool reasons (Spec 098) + displayPreflightSection(activity) + // Arguments if args, ok := activity["arguments"].(map[string]interface{}); ok && len(args) > 0 { fmt.Println() diff --git a/cmd/mcpproxy/activity_preflight_test.go b/cmd/mcpproxy/activity_preflight_test.go new file mode 100644 index 00000000..607cbdf3 --- /dev/null +++ b/cmd/mcpproxy/activity_preflight_test.go @@ -0,0 +1,246 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" +) + +// Spec 098 T022 — `mcpproxy activity list` must accept and render the +// `preflight` activity type (FR-014, US3 acceptance 2). +// +// Every test here feeds the renderer a record that went through the same JSON +// round trip the REST client performs, because that is what turns the stored +// `int` counts into `float64` — the exact shape the CLI actually sees. + +// preflightRecordJSON builds a stored preflight activity record and round-trips +// it through JSON, mirroring GET /api/v1/activity. +func preflightRecordJSON(t *testing.T, rec runtime.PreflightActivity) map[string]interface{} { + t.Helper() + + tools := make([]map[string]interface{}, 0, len(rec.Tools)) + reasons := map[string]int{} + for _, tool := range rec.Tools { + entry := map[string]interface{}{ + storage.PreflightPerToolKeyID: tool.ID, + storage.PreflightPerToolKeyStatus: tool.Status, + } + if tool.Reason != "" { + entry[storage.PreflightPerToolKeyReason] = tool.Reason + reasons[tool.Reason]++ + } + tools = append(tools, entry) + } + + record := map[string]interface{}{ + "id": "01JPREFLIGHT0001", + "type": string(storage.ActivityTypePreflight), + "source": "api", + "status": runtime.PreflightActivityStatus(rec.Verdict), + "request_id": rec.RequestID, + "timestamp": "2026-08-15T10:00:00Z", + "metadata": map[string]interface{}{ + storage.MetadataKeyPreflightVerdict: rec.Verdict, + storage.MetadataKeyPreflightIDsCount: len(rec.Tools), + storage.MetadataKeyPreflightReasons: reasons, + storage.MetadataKeyPreflightPerTool: tools, + }, + } + + encoded, err := json.Marshal(record) + require.NoError(t, err) + var decoded map[string]interface{} + require.NoError(t, json.Unmarshal(encoded, &decoded)) + return decoded +} + +func TestActivityFilter_Validate_AcceptsPreflightType(t *testing.T) { + tests := []struct { + name string + typ string + }{ + {name: "preflight alone", typ: "preflight"}, + {name: "preflight combined with tool_call", typ: "tool_call,preflight"}, + {name: "preflight with surrounding spaces", typ: "preflight, tool_call"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := ActivityFilter{Type: tt.typ} + assert.NoError(t, filter.Validate()) + }) + } +} + +func TestActivityFilter_Validate_StillRejectsUnknownType(t *testing.T) { + filter := ActivityFilter{Type: "preflights"} + err := filter.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid type") +} + +func TestPreflightActivitySummary(t *testing.T) { + t.Run("all ready", func(t *testing.T) { + record := preflightRecordJSON(t, runtime.PreflightActivity{ + Verdict: "ready", + Tools: []runtime.PreflightToolOutcome{ + {ID: "ctl:echo", Status: "ready"}, + {ID: "gh:sync", Status: "ready"}, + }, + }) + assert.Equal(t, "ready (2 tools)", preflightActivitySummary(record)) + }) + + t.Run("single tool is not pluralized", func(t *testing.T) { + record := preflightRecordJSON(t, runtime.PreflightActivity{ + Verdict: "ready", + Tools: []runtime.PreflightToolOutcome{{ID: "ctl:echo", Status: "ready"}}, + }) + assert.Equal(t, "ready (1 tool)", preflightActivitySummary(record)) + }) + + t.Run("reasons are rolled up, most frequent first", func(t *testing.T) { + record := preflightRecordJSON(t, runtime.PreflightActivity{ + Verdict: "blocked", + Tools: []runtime.PreflightToolOutcome{ + {ID: "ctl:echo", Status: "ready"}, + {ID: "gh:sync", Status: "unavailable", Reason: "server_disabled"}, + {ID: "gh:close", Status: "unavailable", Reason: "server_disabled"}, + {ID: "slack:post", Status: "unavailable", Reason: "tool_changed"}, + }, + }) + assert.Equal(t, + "blocked (4 tools): server_disabled x2, tool_changed x1", + preflightActivitySummary(record)) + }) + + t.Run("ties break alphabetically for a stable line", func(t *testing.T) { + record := preflightRecordJSON(t, runtime.PreflightActivity{ + Verdict: "unknown_ids", + Tools: []runtime.PreflightToolOutcome{ + {ID: "a:one", Status: "unavailable", Reason: "not_found"}, + {ID: "b:two", Status: "unavailable", Reason: "server_disabled"}, + {ID: "c:three", Status: "unavailable", Reason: "hash_mismatch"}, + }, + }) + summary := preflightActivitySummary(record) + for i := 0; i < 5; i++ { + assert.Equal(t, summary, preflightActivitySummary(record)) + } + assert.Equal(t, + "unknown_ids (3 tools): hash_mismatch x1, not_found x1, server_disabled x1", + summary) + }) + + t.Run("caps the reason list so the table cell stays readable", func(t *testing.T) { + record := preflightRecordJSON(t, runtime.PreflightActivity{ + Verdict: "blocked", + Tools: []runtime.PreflightToolOutcome{ + {ID: "a:1", Status: "unavailable", Reason: "server_disabled"}, + {ID: "b:1", Status: "unavailable", Reason: "tool_changed"}, + {ID: "c:1", Status: "unavailable", Reason: "not_found"}, + {ID: "d:1", Status: "unavailable", Reason: "hash_mismatch"}, + {ID: "e:1", Status: "unavailable", Reason: "oauth_required"}, + }, + }) + summary := preflightActivitySummary(record) + assert.Contains(t, summary, "+2 more") + assert.Equal(t, 3, strings.Count(summary, " x1")) + }) + + t.Run("falls back to per_tool when the rollup is missing", func(t *testing.T) { + record := map[string]interface{}{ + "type": string(storage.ActivityTypePreflight), + "metadata": map[string]interface{}{ + storage.MetadataKeyPreflightVerdict: "unknown_ids", + storage.MetadataKeyPreflightPerTool: []interface{}{ + map[string]interface{}{ + storage.PreflightPerToolKeyID: "a:1", + storage.PreflightPerToolKeyStatus: "unavailable", + storage.PreflightPerToolKeyReason: "not_found", + }, + }, + }, + } + assert.Equal(t, "unknown_ids (1 tool): not_found x1", preflightActivitySummary(record)) + }) + + t.Run("non-preflight records get no summary", func(t *testing.T) { + assert.Equal(t, "", preflightActivitySummary(map[string]interface{}{ + "type": "tool_call", + "metadata": map[string]interface{}{ + storage.MetadataKeyPreflightVerdict: "ready", + }, + })) + }) + + t.Run("a preflight record without metadata degrades to empty", func(t *testing.T) { + assert.Equal(t, "", preflightActivitySummary(map[string]interface{}{ + "type": string(storage.ActivityTypePreflight), + })) + }) +} + +func TestPreflightDetailLines(t *testing.T) { + record := preflightRecordJSON(t, runtime.PreflightActivity{ + RequestID: "req-098", + Verdict: "blocked", + Tools: []runtime.PreflightToolOutcome{ + {ID: "ctl:echo", Status: "ready"}, + {ID: "gh:sync", Status: "unavailable", Reason: "server_disabled"}, + {ID: "slack:post", Status: "unavailable", Reason: "tool_changed"}, + }, + }) + + lines := preflightDetailLines(record) + require.NotEmpty(t, lines) + joined := strings.Join(lines, "\n") + + assert.Contains(t, joined, "Preflight:") + assert.Contains(t, joined, "Verdict:") + assert.Contains(t, joined, "blocked") + assert.Contains(t, joined, "Tools Checked:") + assert.Contains(t, joined, "3") + assert.Contains(t, joined, "server_disabled x1, tool_changed x1") + + // Per-tool detail keeps the request order and names every reason. + assert.Contains(t, joined, "ctl:echo") + assert.Contains(t, joined, "gh:sync") + assert.Contains(t, joined, "slack:post") + assert.Less(t, strings.Index(joined, "gh:sync"), strings.Index(joined, "slack:post")) + + // A ready tool carries no reason. + for _, line := range lines { + if strings.Contains(line, "ctl:echo") { + assert.NotContains(t, line, "server_disabled") + } + } +} + +func TestPreflightDetailLines_NotAPreflightRecord(t *testing.T) { + assert.Empty(t, preflightDetailLines(map[string]interface{}{"type": "tool_call"})) + assert.Empty(t, preflightDetailLines(map[string]interface{}{ + "type": string(storage.ActivityTypePreflight), + })) +} + +// Tool ids are caller-supplied strings; an id carrying an ANSI escape must not +// reach the operator's terminal raw (same trust boundary as `tools list`). +func TestPreflightRenderingSanitizesToolIDs(t *testing.T) { + record := preflightRecordJSON(t, runtime.PreflightActivity{ + Verdict: "unknown_ids", + Tools: []runtime.PreflightToolOutcome{ + {ID: "\x1b[2J\x1b[1;1Hevil:tool", Status: "unavailable", Reason: "not_found"}, + }, + }) + + joined := strings.Join(preflightDetailLines(record), "\n") + assert.NotContains(t, joined, "\x1b[2J") + assert.Contains(t, joined, "evil:tool") +} diff --git a/cmd/mcpproxy/exit_codes.go b/cmd/mcpproxy/exit_codes.go index cb866234..9e94fc24 100644 --- a/cmd/mcpproxy/exit_codes.go +++ b/cmd/mcpproxy/exit_codes.go @@ -1,5 +1,11 @@ package main +import ( + "fmt" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" +) + // Exit codes for mcpproxy to enable specific error handling by the tray launcher const ( @@ -20,4 +26,57 @@ const ( // ExitCodePermissionError indicates insufficient permissions (file access, port binding) ExitCodePermissionError = 5 + + // Spec 098 preflight verdict codes. They are a SEPARATE band from the codes + // above on purpose: 0-5 describe whether mcpproxy could run, 10-12 describe + // what a preflight found, so a cron wrapper can branch retry-vs-page-vs-fix + // on the exit code alone without parsing JSON (SC-003). Their values are the + // spec's, and preflight.ExitCode is the single mapping — these constants + // exist so the CLI can name them, not to re-derive them. + + // ExitCodePreflightDegradedRetryable: every failure is retryable (the proxy + // is mid-transition). The job should back off and retry. + ExitCodePreflightDegradedRetryable = preflight.ExitDegradedRetryable + + // ExitCodePreflightBlocked: at least one tool needs an operator action + // (approve, enable, log in, re-pin). Retrying will not help. + ExitCodePreflightBlocked = preflight.ExitBlocked + + // ExitCodePreflightUnknownIDs: at least one requested id does not exist in + // the caller's view — usually a typo or a removed server. + ExitCodePreflightUnknownIDs = preflight.ExitUnknownIDs ) + +// preflightVerdictError carries a non-ready preflight verdict out of the +// subcommand so the CENTRAL classifier assigns the exit code. +// +// The subcommand deliberately cannot call os.Exit with a code of its own: every +// exit code mcpproxy returns is decided in one place (classifyError), which is +// what keeps 10/11/12 from drifting into meaning something else in a second +// command later. +type preflightVerdictError struct { + verdict string + summary string +} + +func (e *preflightVerdictError) Error() string { + if e.summary == "" { + return fmt.Sprintf("preflight verdict: %s", e.verdict) + } + return fmt.Sprintf("preflight verdict: %s (%s)", e.verdict, e.summary) +} + +// ExitCode is the spec's worst-class-wins mapping, delegated to the evaluator +// package so the table lives once. +func (e *preflightVerdictError) ExitCode() int { + return preflight.ExitCode(e.verdict) +} + +// newPreflightVerdictError returns nil for a ready verdict — a successful +// preflight is not an error — and a typed error otherwise. +func newPreflightVerdictError(verdict, summary string) error { + if verdict == preflight.VerdictReady || verdict == "" { + return nil + } + return &preflightVerdictError{verdict: verdict, summary: summary} +} diff --git a/cmd/mcpproxy/main.go b/cmd/mcpproxy/main.go index 34d597bd..0cb90080 100644 --- a/cmd/mcpproxy/main.go +++ b/cmd/mcpproxy/main.go @@ -779,6 +779,16 @@ func classifyError(err error) int { return ExitCodeSuccess } + // Spec 098: a preflight verdict is a RESULT, not a failure of mcpproxy, and + // it carries its own exit code (10/11/12). It is checked first so the + // string-matching heuristics below — "config", "invalid", "denied" all + // appear in remediation text — can never reclassify a verdict as a config + // or permission error. + var preflightErr *preflightVerdictError + if errors.As(err, &preflightErr) { + return preflightErr.ExitCode() + } + // Check for port conflict errors var portErr *server.PortInUseError if errors.As(err, &portErr) { diff --git a/cmd/mcpproxy/preflight_cmd_test.go b/cmd/mcpproxy/preflight_cmd_test.go new file mode 100644 index 00000000..56e39726 --- /dev/null +++ b/cmd/mcpproxy/preflight_cmd_test.go @@ -0,0 +1,375 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/cli/output" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" +) + +// --- T017: typed exit-code error + central classification ------------------- + +func TestPreflightVerdictError_MapsToSpecExitCodes(t *testing.T) { + tests := []struct { + verdict string + want int + }{ + {preflight.VerdictDegradedRetryable, ExitCodePreflightDegradedRetryable}, + {preflight.VerdictBlocked, ExitCodePreflightBlocked}, + {preflight.VerdictUnknownIDs, ExitCodePreflightUnknownIDs}, + } + for _, tc := range tests { + t.Run(tc.verdict, func(t *testing.T) { + err := newPreflightVerdictError(tc.verdict, "1 of 1 tools unavailable") + require.Error(t, err) + assert.Equal(t, tc.want, classifyError(err), "the CENTRAL classifier owns the exit code") + assert.Contains(t, err.Error(), tc.verdict) + }) + } + + assert.Equal(t, 10, ExitCodePreflightDegradedRetryable) + assert.Equal(t, 11, ExitCodePreflightBlocked) + assert.Equal(t, 12, ExitCodePreflightUnknownIDs) +} + +func TestPreflightVerdictError_ReadyIsNotAnError(t *testing.T) { + assert.NoError(t, newPreflightVerdictError(preflight.VerdictReady, "")) + assert.Equal(t, ExitCodeSuccess, classifyError(nil)) +} + +// The verdict error must survive wrapping — a caller that adds context must not +// silently downgrade the exit code to the generic 1. +func TestPreflightVerdictError_SurvivesWrapping(t *testing.T) { + wrapped := fmt.Errorf("running scheduled job: %w", newPreflightVerdictError(preflight.VerdictBlocked, "")) + assert.Equal(t, ExitCodePreflightBlocked, classifyError(wrapped)) +} + +// Remediation text is full of words the string-matching heuristics look for +// ("configure", "invalid", "denies", "permission"). The verdict check runs +// first precisely so none of them can reclassify a verdict. +func TestPreflightVerdictError_NotReclassifiedByStringHeuristics(t *testing.T) { + err := newPreflightVerdictError(preflight.VerdictBlocked, + "1 of 1 tools unavailable: tool_denied_by_config (invalid configuration, permission denied)") + assert.Equal(t, ExitCodePreflightBlocked, classifyError(err)) +} + +// Transport and argument failures keep the general exit code 1 — they are not +// verdicts and a cron wrapper must be able to tell them apart. +func TestPreflightTransportErrorsUseGeneralExitCode(t *testing.T) { + assert.Equal(t, ExitCodeGeneralError, classifyError(errors.New("mcpproxy daemon is not reachable. Start with: mcpproxy serve"))) +} + +// --- T018: exit-code precedence (worst class wins, 12 > 11 > 10) ------------ + +func TestPreflightExitVerdict_WorstClassWins(t *testing.T) { + result := func(id, reason string) contracts.PreflightToolResult { + if reason == "" { + return contracts.PreflightToolResult{ID: id, Status: preflight.StatusReady} + } + retryable := preflight.Retryable(reason) + return contracts.PreflightToolResult{ + ID: id, Status: preflight.StatusUnavailable, Reason: reason, Retryable: &retryable, + } + } + + tests := []struct { + name string + tools []contracts.PreflightToolResult + wantExit int + }{ + { + name: "all ready", + tools: []contracts.PreflightToolResult{result("a:1", ""), result("a:2", "")}, + wantExit: 0, + }, + { + name: "retryable only", + tools: []contracts.PreflightToolResult{result("a:1", ""), result("a:2", preflight.ReasonServerInitializing)}, + wantExit: 10, + }, + { + name: "blocked beats retryable", + tools: []contracts.PreflightToolResult{ + result("a:1", preflight.ReasonServerInitializing), + result("a:2", preflight.ReasonToolChanged), + }, + wantExit: 11, + }, + { + name: "unknown id beats blocked and retryable", + tools: []contracts.PreflightToolResult{ + result("a:1", preflight.ReasonServerInitializing), + result("a:2", preflight.ReasonToolChanged), + result("a:3", preflight.ReasonNotFound), + }, + wantExit: 12, + }, + { + name: "server_not_configured is an unknown id", + tools: []contracts.PreflightToolResult{result("ghost:1", preflight.ReasonServerNotConfigured)}, + wantExit: 12, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resp := &contracts.PreflightResponse{ + // The daemon's own verdict is deliberately understated here so + // the local aggregation is what the assertion measures. + Verdict: preflight.VerdictReady, + Tools: tc.tools, + } + verdict := preflightExitVerdict(resp) + assert.Equal(t, tc.wantExit, preflight.ExitCode(verdict)) + assert.Equal(t, tc.wantExit, classifyErrorOrZero(newPreflightVerdictError(verdict, ""))) + }) + } +} + +// The daemon's verdict still wins when it is the WORSE reading — the local +// recomputation may only escalate, never soften. +func TestPreflightExitVerdict_TakesTheWorseOfBothReadings(t *testing.T) { + resp := &contracts.PreflightResponse{ + Verdict: preflight.VerdictBlocked, + Tools: []contracts.PreflightToolResult{{ID: "a:1", Status: preflight.StatusReady}}, + } + assert.Equal(t, preflight.VerdictBlocked, preflightExitVerdict(resp)) +} + +func classifyErrorOrZero(err error) int { + if err == nil { + return 0 + } + return classifyError(err) +} + +// --- T018: request building ------------------------------------------------- + +func TestBuildPreflightRequest(t *testing.T) { + t.Run("ids, pins, profile, filters and wait", func(t *testing.T) { + req, err := buildPreflightRequest( + []string{"ctl:echo", "ctl:add"}, + []string{"ctl:echo=sha256/v1:abcd"}, + "work", + 5*time.Second, + contracts.PreflightPolicy{ReadOnlyOnly: true, ExcludeDestructive: true}, + ) + require.NoError(t, err) + require.Len(t, req.Tools, 2) + assert.Equal(t, "ctl:echo", req.Tools[0].ID) + assert.Equal(t, "sha256/v1:abcd", req.Tools[0].PinHash) + assert.Equal(t, "ctl:add", req.Tools[1].ID) + assert.Empty(t, req.Tools[1].PinHash) + assert.Equal(t, "work", req.Profile) + assert.Equal(t, 5000, req.WaitMS) + require.NotNil(t, req.Policy) + assert.True(t, req.Policy.ReadOnlyOnly) + assert.True(t, req.Policy.ExcludeDestructive) + assert.False(t, req.Policy.ExcludeOpenWorld) + }) + + t.Run("no filters means no policy object", func(t *testing.T) { + req, err := buildPreflightRequest([]string{"ctl:echo"}, nil, "", 0, contracts.PreflightPolicy{}) + require.NoError(t, err) + assert.Nil(t, req.Policy) + assert.Zero(t, req.WaitMS) + }) + + t.Run("pin for an id that was not requested is a usage error", func(t *testing.T) { + _, err := buildPreflightRequest([]string{"ctl:echo"}, []string{"ctl:other=sha256/v1:abcd"}, "", 0, contracts.PreflightPolicy{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "ctl:other") + }) + + t.Run("malformed pin", func(t *testing.T) { + for _, pin := range []string{"ctl:echo", "=hash", "ctl:echo="} { + _, err := buildPreflightRequest([]string{"ctl:echo"}, []string{pin}, "", 0, contracts.PreflightPolicy{}) + assert.Error(t, err, "pin %q must be rejected", pin) + } + }) + + t.Run("conflicting pins for one id", func(t *testing.T) { + _, err := buildPreflightRequest([]string{"ctl:echo"}, + []string{"ctl:echo=sha256/v1:aa", "ctl:echo=sha256/v1:bb"}, "", 0, contracts.PreflightPolicy{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "conflicting") + }) + + t.Run("a hash containing colons and slashes survives the split", func(t *testing.T) { + req, err := buildPreflightRequest([]string{"ctl:echo"}, []string{"ctl:echo=sha256/v2:deadbeef"}, "", 0, contracts.PreflightPolicy{}) + require.NoError(t, err) + assert.Equal(t, "sha256/v2:deadbeef", req.Tools[0].PinHash) + }) +} + +// --- T018: output formats --------------------------------------------------- + +func samplePreflightResponse() *contracts.PreflightResponse { + retryable := true + waited := 750 + return &contracts.PreflightResponse{ + Verdict: preflight.VerdictDegradedRetryable, + CheckedAt: time.Date(2026, 8, 15, 10, 0, 0, 0, time.UTC), + WaitedMS: &waited, + Tools: []contracts.PreflightToolResult{ + {ID: "ctl:echo", Status: preflight.StatusReady}, + { + ID: "ctl:add", + Status: preflight.StatusUnavailable, + Reason: preflight.ReasonServerInitializing, + Retryable: &retryable, + Detail: "Server \"ctl\" is still starting up.", + Remediation: preflight.DefaultRemediation(preflight.ReasonServerInitializing), + }, + }, + } +} + +func TestRenderPreflight_JSONUsesWireKeys(t *testing.T) { + rendered, err := renderPreflight("json", samplePreflightResponse()) + require.NoError(t, err) + + var decoded map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(rendered), &decoded)) + assert.Equal(t, preflight.VerdictDegradedRetryable, decoded["verdict"]) + assert.Contains(t, decoded, "checked_at") + assert.EqualValues(t, 750, decoded["waited_ms"]) + tools, ok := decoded["tools"].([]interface{}) + require.True(t, ok) + require.Len(t, tools, 2) + first := tools[0].(map[string]interface{}) + assert.Equal(t, "ctl:echo", first["id"]) + assert.NotContains(t, first, "reason", "a ready result carries no failure fields") + second := tools[1].(map[string]interface{}) + assert.Equal(t, preflight.ReasonServerInitializing, second["reason"]) + assert.Equal(t, true, second["retryable"]) +} + +// YAML must use the SAME key names as JSON — a wrapper switching -o must not +// have to learn a second vocabulary. +func TestRenderPreflight_YAMLUsesTheSameKeysAsJSON(t *testing.T) { + rendered, err := renderPreflight("yaml", samplePreflightResponse()) + require.NoError(t, err) + + var decoded map[string]interface{} + require.NoError(t, yaml.Unmarshal([]byte(rendered), &decoded)) + assert.Equal(t, preflight.VerdictDegradedRetryable, decoded["verdict"]) + assert.Contains(t, decoded, "checked_at") + assert.Contains(t, decoded, "waited_ms") + assert.NotContains(t, decoded, "waitedms", "yaml must honour the json tags, not the Go field names") +} + +func TestRenderPreflight_TableCarriesVerdictAndPerToolReasons(t *testing.T) { + rendered, err := renderPreflight("table", samplePreflightResponse()) + require.NoError(t, err) + + assert.Contains(t, rendered, "VERDICT: degraded_retryable (exit 10)") + assert.Contains(t, rendered, "CHECKED: 2026-08-15T10:00:00Z") + assert.Contains(t, rendered, "WAITED: 750ms") + assert.Contains(t, rendered, "ctl:echo") + assert.Contains(t, rendered, "ctl:add") + assert.Contains(t, rendered, preflight.ReasonServerInitializing) + assert.Contains(t, rendered, "ID") + assert.Contains(t, rendered, "RETRYABLE") +} + +func TestRenderPreflight_UnknownFormatIsAStructuredError(t *testing.T) { + _, err := renderPreflight("xml", samplePreflightResponse()) + require.Error(t, err) + var structured output.StructuredError + require.True(t, errors.As(err, &structured)) + assert.Equal(t, output.ErrCodeInvalidOutputFormat, structured.Code) +} + +// MCPPROXY_OUTPUT drives the format when no -o flag is given, per the repo's +// CLI output conventions (FR-009). +func TestPreflightOutputFormatHonoursEnvVar(t *testing.T) { + t.Setenv("MCPPROXY_OUTPUT", "json") + prevFormat, prevJSON := globalOutputFormat, globalJSONOutput + t.Cleanup(func() { globalOutputFormat, globalJSONOutput = prevFormat, prevJSON }) + globalOutputFormat = "" + globalJSONOutput = false + + format := ResolveOutputFormat() + require.Equal(t, "json", format) + + rendered, err := renderPreflight(format, samplePreflightResponse()) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(strings.TrimSpace(rendered), "{")) +} + +func TestPreflightSummaryNamesTheFailingReasons(t *testing.T) { + summary := preflightSummary(samplePreflightResponse()) + assert.Contains(t, summary, "1 of 2 tools unavailable") + assert.Contains(t, summary, preflight.ReasonServerInitializing) + + ready := &contracts.PreflightResponse{ + Verdict: preflight.VerdictReady, + Tools: []contracts.PreflightToolResult{{ID: "ctl:echo", Status: preflight.StatusReady}}, + } + assert.Empty(t, preflightSummary(ready)) +} + +// --- T018: command wiring & --help-json metadata ---------------------------- + +func TestToolsPreflightCommand_FlagsAndHelpJSON(t *testing.T) { + cmd := newToolsPreflightCmd() + + require.NotNil(t, cmd.Args, "at least one tool id is required") + assert.Error(t, cmd.Args(cmd, nil), "no ids must be rejected before any request is made") + assert.NoError(t, cmd.Args(cmd, []string{"ctl:echo"})) + + // --help-json must reach the help hook even with no ids: it is the + // discovery call an agent makes BEFORE it knows what to pass. + withHelpJSON := newToolsPreflightCmd() + withHelpJSON.Flags().Bool("help-json", false, "") + require.NoError(t, withHelpJSON.Flags().Set("help-json", "true")) + assert.NoError(t, withHelpJSON.Args(withHelpJSON, nil)) + + info := output.ExtractHelpInfo(cmd) + assert.Equal(t, "preflight", info.Name) + assert.NotEmpty(t, info.Description) + + flagNames := make(map[string]string, len(info.Flags)) + for _, f := range info.Flags { + flagNames[f.Name] = f.Type + } + for name, wantType := range map[string]string{ + "profile": "string", + "pin": "stringArray", + "read-only-only": "bool", + "exclude-destructive": "bool", + "exclude-open-world": "bool", + "wait": "duration", + } { + gotType, ok := flagNames[name] + assert.True(t, ok, "--%s must appear in --help-json metadata", name) + assert.Equal(t, wantType, gotType, "--%s type", name) + } + + // The exit-code contract is the command's whole point, so it must be + // discoverable from the help text alone. + for _, code := range []string{"10", "11", "12"} { + assert.Contains(t, cmd.Long, code) + } +} + +func TestToolsPreflightCommand_IsRegisteredUnderTools(t *testing.T) { + var found bool + for _, sub := range GetToolsCommand().Commands() { + if sub.Name() == "preflight" { + found = true + } + } + assert.True(t, found, "tools preflight must be registered on the tools command") +} diff --git a/cmd/mcpproxy/tools_cmd.go b/cmd/mcpproxy/tools_cmd.go index 43b6c684..f13ad7b8 100644 --- a/cmd/mcpproxy/tools_cmd.go +++ b/cmd/mcpproxy/tools_cmd.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "os" "path/filepath" @@ -12,7 +13,9 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/cli/output" "github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient" "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" "github.com/smart-mcp-proxy/mcpproxy-go/internal/logs" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" "github.com/smart-mcp-proxy/mcpproxy-go/internal/secret" "github.com/smart-mcp-proxy/mcpproxy-go/internal/security/detect" "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" @@ -196,6 +199,7 @@ func init() { toolsCmd.AddCommand(toolsDisableCmd) toolsCmd.AddCommand(newToolsApproveCmd()) toolsCmd.AddCommand(newToolsRejectCmd()) + toolsCmd.AddCommand(newToolsPreflightCmd()) initToolsFlags() } @@ -827,3 +831,336 @@ func runToolsListStandalone(ctx context.Context, serverName string, globalConfig return outputToolsFromMetadata(tools, serverName) } + +// --- Spec 098: tools preflight ---------------------------------------------- + +// newToolsPreflightCmd builds `mcpproxy tools preflight`, the cron/CI gate: one +// deterministic, side-effect-free check that answers "are these tools usable +// right now?" before a job spends model tokens finding out the hard way. +// +// The exit code is the product: 0 all ready, 10 retryable (back off), 11 an +// operator has to act, 12 an id does not exist. A wrapper can branch on it +// without parsing any JSON. +func newToolsPreflightCmd() *cobra.Command { + var ( + profile string + pins []string + readOnlyOnly bool + excludeDestructive bool + excludeOpenWorld bool + wait time.Duration + ) + + cmd := &cobra.Command{ + Use: "preflight [...]", + Short: "Check that required tools are ready, without calling any upstream server", + Long: `Check a list of required tools against local proxy state and report, per tool, +whether it is ready or exactly why it is not. + +The check performs zero upstream calls and changes nothing: it reads the tool +index, approval records, connection state and configuration policy only. + +Exit codes (worst class present wins): + 0 every tool is ready + 10 degraded but retryable (a server is starting up or unhealthy) — back off and retry + 11 blocked: an operator action is needed (approve, enable, log in, re-pin) + 12 at least one requested id is unknown in your view (typo, or removed server) + 1 the command itself failed (daemon unreachable, invalid arguments) + +Examples: + mcpproxy tools preflight gh-ops:sync_issues slack:post_message + mcpproxy tools preflight ctl:echo -o json + mcpproxy tools preflight ctl:echo --pin ctl:echo=sha256/v1:9f86d0... + mcpproxy tools preflight ctl:echo --profile work --wait 5s + mcpproxy tools preflight ctl:echo --read-only-only`, + // Tool ids are required — except under --help-json, which is a + // discovery call an agent makes before it knows what to pass. Cobra + // validates Args before the --help-json hook runs, so a plain + // MinimumNArgs(1) would make the command's own metadata unreachable. + Args: preflightArgs, + RunE: func(cmd *cobra.Command, args []string) error { + request, err := buildPreflightRequest(args, pins, profile, wait, + contracts.PreflightPolicy{ + ReadOnlyOnly: readOnlyOnly, + ExcludeDestructive: excludeDestructive, + ExcludeOpenWorld: excludeOpenWorld, + }) + if err != nil { + // Argument errors keep the usage block: the operator mistyped + // the invocation and the syntax is the answer. + return err + } + cmd.SilenceUsage = true + // From here the command's return value is a VERDICT, not a usage + // problem. Cobra would print it a second time on top of the central + // handler's "Error: …" line, and a cron log with the same verdict + // twice reads like two failures. + cmd.SilenceErrors = true + return runToolsPreflight(request) + }, + } + + cmd.Flags().StringVar(&profile, "profile", "", "Evaluate under a named profile's server scope") + cmd.Flags().StringArrayVar(&pins, "pin", nil, "Pin a tool to a schema hash: --pin =sha256/v: (repeatable)") + cmd.Flags().BoolVar(&readOnlyOnly, "read-only-only", false, "Require tools to be annotated read-only") + cmd.Flags().BoolVar(&excludeDestructive, "exclude-destructive", false, "Require tools to be annotated non-destructive") + cmd.Flags().BoolVar(&excludeOpenWorld, "exclude-open-world", false, "Require tools to be annotated closed-world") + cmd.Flags().DurationVar(&wait, "wait", 0, "Poll local state for up to this long while every failure is retryable (max 10s)") + + return cmd +} + +// preflightArgs requires at least one tool id, but lets `--help-json` through +// with none: that flag is answered by a PersistentPreRunE hook, which cobra +// runs AFTER argument validation, so a bare MinimumNArgs(1) would hide the +// command's machine-readable help from the agents it exists for. +func preflightArgs(cmd *cobra.Command, args []string) error { + if helpJSON, err := cmd.Flags().GetBool("help-json"); err == nil && helpJSON { + return nil + } + return cobra.MinimumNArgs(1)(cmd, args) +} + +// buildPreflightRequest turns CLI arguments into the REST request body. +// +// It validates only what is genuinely local (pin syntax, pins naming an id that +// was not requested). Everything else — the 100-id cap, the wait cap, unknown +// profiles — is the daemon's rule, and duplicating it here would give the two +// surfaces two chances to disagree. +func buildPreflightRequest(ids, pins []string, profile string, wait time.Duration, policy contracts.PreflightPolicy) (*contracts.PreflightRequest, error) { + pinByID, err := parsePreflightPins(pins) + if err != nil { + return nil, err + } + + request := &contracts.PreflightRequest{ + Tools: make([]contracts.PreflightToolRef, 0, len(ids)), + Profile: strings.TrimSpace(profile), + WaitMS: int(wait.Milliseconds()), + } + if policy.ReadOnlyOnly || policy.ExcludeDestructive || policy.ExcludeOpenWorld { + policyCopy := policy + request.Policy = &policyCopy + } + + requested := make(map[string]bool, len(ids)) + for _, raw := range ids { + id := strings.TrimSpace(raw) + if id == "" { + return nil, fmt.Errorf("empty tool id in arguments (expected :)") + } + requested[id] = true + request.Tools = append(request.Tools, contracts.PreflightToolRef{ID: id, PinHash: pinByID[id]}) + } + + for id := range pinByID { + if !requested[id] { + return nil, fmt.Errorf("--pin names %q, which is not in the requested tool list", id) + } + } + + return request, nil +} + +// parsePreflightPins parses repeatable `--pin =` flags. The split is +// on the FIRST '=' because a pin value is "sha256/v1:" — it carries ':' +// and '/', but never '=' — while the id carries ':'. +func parsePreflightPins(pins []string) (map[string]string, error) { + out := make(map[string]string, len(pins)) + for _, pin := range pins { + idx := strings.Index(pin, "=") + if idx <= 0 { + return nil, fmt.Errorf("invalid --pin %q: expected :=", pin) + } + id := strings.TrimSpace(pin[:idx]) + hash := strings.TrimSpace(pin[idx+1:]) + if id == "" || hash == "" { + return nil, fmt.Errorf("invalid --pin %q: expected :=", pin) + } + if existing, ok := out[id]; ok && existing != hash { + return nil, fmt.Errorf("conflicting --pin values for %q: %q and %q", id, existing, hash) + } + out[id] = hash + } + return out, nil +} + +// runToolsPreflight calls the daemon, renders the result, and converts a +// non-ready verdict into the typed exit-code error. +func runToolsPreflight(request *contracts.PreflightRequest) error { + client, _, err := newSecurityCLIClient() + if err != nil { + return err + } + + // The request's own wait budget is capped at 10s daemon-side; the transport + // deadline just has to outlive it. + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + response, err := client.Preflight(ctx, request) + if err != nil { + return cliError("preflight failed", err) + } + + rendered, err := renderPreflight(ResolveOutputFormat(), response) + if err != nil { + return err + } + fmt.Print(rendered) + + // The verdict is not a command failure — it is the answer. It travels as a + // typed error purely so the CENTRAL classifier assigns 10/11/12. + return newPreflightVerdictError(preflightExitVerdict(response), preflightSummary(response)) +} + +// preflightExitVerdict is the verdict the exit code comes from: the worse of +// what the daemon reported and what the per-tool results imply. +// +// Recomputing locally is deliberate. The exit code is the contract a cron +// wrapper trusts, and taking the max of both readings means an older daemon +// that under-reports the set verdict can never make a blocked tool look like a +// clean run. Both readings use the same locked table (preflight.ExitCode), so +// "worse" is just the higher exit code. +func preflightExitVerdict(response *contracts.PreflightResponse) string { + if response == nil { + return preflight.VerdictReady + } + reasons := make([]string, 0, len(response.Tools)) + for _, tool := range response.Tools { + if tool.Status == preflight.StatusReady { + continue + } + reasons = append(reasons, tool.Reason) + } + worst := preflight.VerdictForReasons(reasons) + if preflight.ExitCode(response.Verdict) > preflight.ExitCode(worst) { + return response.Verdict + } + return worst +} + +// preflightSummary is the one-line context that rides along with the exit-code +// error, e.g. "2 of 5 tools unavailable: server_disabled, not_found". +func preflightSummary(response *contracts.PreflightResponse) string { + if response == nil { + return "" + } + unavailable := 0 + seen := make(map[string]bool) + var reasons []string + for _, tool := range response.Tools { + if tool.Status == preflight.StatusReady { + continue + } + unavailable++ + if tool.Reason != "" && !seen[tool.Reason] { + seen[tool.Reason] = true + reasons = append(reasons, tool.Reason) + } + } + if unavailable == 0 { + return "" + } + summary := fmt.Sprintf("%d of %d tools unavailable", unavailable, len(response.Tools)) + if len(reasons) > 0 { + summary += ": " + strings.Join(reasons, ", ") + } + return summary +} + +// renderPreflight formats one response for the requested output format. It is +// pure (returns the string instead of printing) so every format is unit-tested +// without capturing stdout. +func renderPreflight(outputFormat string, response *contracts.PreflightResponse) (string, error) { + formatter, err := output.NewFormatter(outputFormat) + if err != nil { + return "", output.NewStructuredError(output.ErrCodeInvalidOutputFormat, err.Error()). + WithGuidance("Use -o table, -o json, or -o yaml") + } + + if outputFormat == "json" || outputFormat == "yaml" { + // Marshal through the wire DTO's JSON tags so `-o yaml` emits the same + // key names as `-o json` and the REST payload, rather than yaml's + // lowercased Go field names. + payload, convErr := preflightWirePayload(response) + if convErr != nil { + return "", convErr + } + rendered, fmtErr := formatter.Format(payload) + if fmtErr != nil { + return "", fmt.Errorf("failed to format output: %w", fmtErr) + } + return rendered + "\n", nil + } + + headers, rows := preflightRows(response) + table, fmtErr := formatter.FormatTable(headers, rows) + if fmtErr != nil { + return "", fmt.Errorf("failed to format table: %w", fmtErr) + } + + var b strings.Builder + fmt.Fprintf(&b, "VERDICT: %s (exit %d)\n", response.Verdict, preflight.ExitCode(preflightExitVerdict(response))) + fmt.Fprintf(&b, "CHECKED: %s\n", response.CheckedAt.Format(time.RFC3339)) + if response.WaitedMS != nil { + fmt.Fprintf(&b, "WAITED: %dms\n", *response.WaitedMS) + } + b.WriteString("\n") + b.WriteString(table) + return b.String(), nil +} + +// preflightWirePayload converts the response to generic JSON values so the +// YAML formatter honours the wire key names. +func preflightWirePayload(response *contracts.PreflightResponse) (map[string]interface{}, error) { + encoded, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to encode preflight response: %w", err) + } + var payload map[string]interface{} + if err := json.Unmarshal(encoded, &payload); err != nil { + return nil, fmt.Errorf("failed to decode preflight response: %w", err) + } + return payload, nil +} + +// preflightRows builds the table view. Detail and remediation are sanitized: +// detail can quote an upstream-controlled server or tool name. +func preflightRows(response *contracts.PreflightResponse) (headers []string, rows [][]string) { + headers = []string{"ID", "STATUS", "REASON", "RETRYABLE", "ACTION", "DETAIL"} + rows = make([][]string, 0, len(response.Tools)) + for _, tool := range response.Tools { + reason := tool.Reason + retryable := "" + if tool.Retryable != nil { + retryable = fmt.Sprintf("%t", *tool.Retryable) + } + action := tool.Action + if reason == "" { + reason = "-" + } + if retryable == "" { + retryable = "-" + } + if action == "" { + action = "-" + } + detail := tool.Detail + if detail == "" { + detail = tool.Remediation + } + if detail == "" { + detail = "-" + } + rows = append(rows, []string{ + sanitizeName(tool.ID), + tool.Status, + reason, + retryable, + action, + sanitizeCell(detail, maxToolDescriptionCell), + }) + } + return headers, rows +} diff --git a/cmd/mcpproxy/tools_hash_pin_test.go b/cmd/mcpproxy/tools_hash_pin_test.go new file mode 100644 index 00000000..7c6de199 --- /dev/null +++ b/cmd/mcpproxy/tools_hash_pin_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// T020 (Spec 098 FR-011): `mcpproxy tools list -o json` is the CLI half of the +// hash-pin authoring surface — the value it prints under "hash" is pasted +// straight into `mcpproxy tools preflight --pin =`. The renderers +// pass the daemon payload through untouched, so these tests are the guard +// against a future typed-struct refactor silently dropping the field. + +func captureToolsOutput(t *testing.T, format string, run func() error) string { + t.Helper() + oldStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + defer func() { os.Stdout = oldStdout }() + + oldFormat, oldJSON := globalOutputFormat, globalJSONOutput + globalOutputFormat, globalJSONOutput = format, false + defer func() { globalOutputFormat, globalJSONOutput = oldFormat, oldJSON }() + + runErr := run() + + w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + require.NoError(t, runErr) + return buf.String() +} + +func toolsWithPin() []map[string]interface{} { + return []map[string]interface{}{ + { + "name": "create_issue", + "server_name": "github", + "description": "Create a new GitHub issue", + "approval_status": "approved", + "hash": "sha256/v3:abc123", + }, + { + // No stored hash: the field is simply absent, never a placeholder. + "name": "no_record", + "server_name": "github", + "description": "Never approved", + }, + } +} + +func TestToolsList_JSONCarriesHashPin(t *testing.T) { + for name, run := range map[string]func() error{ + "global": func() error { return outputGlobalTools(toolsWithPin()) }, + "per-server": func() error { return outputTools(toolsWithPin(), nil) }, + } { + t.Run(name, func(t *testing.T) { + out := captureToolsOutput(t, "json", run) + + var parsed []map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(out), &parsed)) + require.Len(t, parsed, 2) + assert.Equal(t, "sha256/v3:abc123", parsed[0]["hash"], + "the pin an operator copies into --pin must survive JSON rendering") + assert.NotContains(t, parsed[1], "hash") + }) + } +} + +// The pin is a long opaque string; the human table stays as it was (#938 +// columns) so it is not pushed off screen. JSON is the authoring surface. +func TestToolsList_TableOmitsHashPin(t *testing.T) { + out := captureToolsOutput(t, "table", func() error { return outputGlobalTools(toolsWithPin()) }) + assert.NotContains(t, out, "sha256/v3:abc123") + assert.Contains(t, out, "create_issue") +} diff --git a/frontend/src/components/ActivityWidget.vue b/frontend/src/components/ActivityWidget.vue index 11756f80..2fa8fda7 100644 --- a/frontend/src/components/ActivityWidget.vue +++ b/frontend/src/components/ActivityWidget.vue @@ -56,6 +56,13 @@
{{ activity.server_name }} :{{ activity.tool_name }} + + + {{ formatPreflightSummary(activity.metadata) || 'Preflight' }} +
{{ formatRelativeTime(activity.timestamp) }}
@@ -77,6 +84,7 @@ import { ref, onMounted } from 'vue' import { useRouter } from 'vue-router' import api from '@/services/api' import type { ActivityRecord, ActivitySummaryResponse } from '@/types/api' +import { formatPreflightSummary, isPreflightActivity } from '@/utils/activity' const router = useRouter() @@ -134,7 +142,9 @@ const getTypeIcon = (type: string): string => { 'tool_call': '🔧', 'policy_decision': '🛡️', 'quarantine_change': '⚠️', - 'server_change': '🔄' + 'server_change': '🔄', + // Spec 098: required-tools preflight + 'preflight': '🛫' } return typeIcons[type] || '📋' } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 3c317b2b..412aecef 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -724,6 +724,13 @@ export type ActivityType = | 'policy_decision' | 'quarantine_change' | 'server_change' + /** + * Spec 098: one executed required-tools preflight. Set-scoped, not + * server-scoped — server_name/tool_name are empty and the verdict, + * requested-id count and per-tool reason codes live in `metadata` + * ({verdict, ids_count, reasons{code:count}, per_tool[{id,status,reason?}]}). + */ + | 'preflight' export type ActivitySource = 'mcp' | 'cli' | 'api' diff --git a/frontend/src/types/contracts.ts b/frontend/src/types/contracts.ts index da6b2566..0777be0a 100644 --- a/frontend/src/types/contracts.ts +++ b/frontend/src/types/contracts.ts @@ -64,6 +64,85 @@ export type RejectionReason = 'queue_full' | 'queue_timeout'; /** Limiter tier that shed the call (activity metadata rejection_scope). */ export type RejectionScope = 'server' | 'global'; +// Preflight (Spec 098) - generated from internal/contracts/types.go +export const PreflightStatusReady = 'ready' as const; +export const PreflightStatusUnavailable = 'unavailable' as const; +export type PreflightStatus = typeof PreflightStatusReady | typeof PreflightStatusUnavailable; + +/** + * Closed 15-code failure enum. Additive-only: treat an unknown code as + * non-retryable. 'server_saturated' is reserved and not emitted. + */ +export type PreflightReason = + | 'server_initializing' + | 'server_unhealthy' + | 'server_disabled' + | 'server_quarantined' + | 'tool_pending_approval' + | 'tool_changed' + | 'tool_blocked_by_user' + | 'oauth_required' + | 'hash_mismatch' + | 'server_not_in_scope' + | 'tool_denied_by_config' + | 'missing_annotation' + | 'policy_filtered' + | 'not_found' + | 'server_not_configured'; + +/** Set-level aggregate (worst class present); drives the CLI exit code 0/10/11/12. */ +export const PreflightVerdictReady = 'ready' as const; +export const PreflightVerdictDegradedRetryable = 'degraded_retryable' as const; +export const PreflightVerdictBlocked = 'blocked' as const; +export const PreflightVerdictUnknownIds = 'unknown_ids' as const; +export type PreflightVerdict = + | typeof PreflightVerdictReady + | typeof PreflightVerdictDegradedRetryable + | typeof PreflightVerdictBlocked + | typeof PreflightVerdictUnknownIds; + +export interface PreflightToolRef { + id: string; + /** "sha256/v{N}:{hex}" - the schema version distinguishes a proxy hash bump from upstream drift. */ + pin_hash?: string; +} + +export interface PreflightPolicy { + read_only_only?: boolean; + exclude_destructive?: boolean; + exclude_open_world?: boolean; +} + +export interface PreflightRequest { + tools: PreflightToolRef[]; + profile?: string; + policy?: PreflightPolicy; + wait_ms?: number; +} + +export interface PreflightToolResult { + id: string; + status: PreflightStatus; + /** Present only when status is 'unavailable'. */ + reason?: PreflightReason; + retryable?: boolean; + /** Health-action vocabulary; omitted (not 'none') when the reason has no action. */ + action?: HealthAction; + detail?: string; + remediation?: string; + /** Operator tier + ready results only; never disclosed to an agent token. */ + hash?: string; + /** Up to 3 nearest caller-visible ids, on not_found only. */ + did_you_mean?: string[]; +} + +export interface PreflightResponse { + verdict: PreflightVerdict; + checked_at: string; // RFC3339 + waited_ms?: number; + tools: PreflightToolResult[]; +} + export interface Server { id: string; name: string; @@ -185,6 +264,10 @@ export interface Tool { held_reason?: string; held_verdict?: string; held_signals?: string[]; + // The tool's current hash in the preflight pin format "sha256/v{N}:{hex}" + // (spec 098 FR-011) — the value to paste into a preflight pin. Operator tier + // only: absent for agent-token callers and for tools with no stored hash. + hash?: string; } export interface SearchResult { diff --git a/frontend/src/utils/activity.ts b/frontend/src/utils/activity.ts index 1f0e809d..6ef4c1cd 100644 --- a/frontend/src/utils/activity.ts +++ b/frontend/src/utils/activity.ts @@ -8,7 +8,9 @@ const typeLabels: Record = { 'tool_call': 'Tool Call', 'policy_decision': 'Policy Decision', 'quarantine_change': 'Quarantine Change', - 'server_change': 'Server Change' + 'server_change': 'Server Change', + // Spec 098: one executed required-tools preflight. + 'preflight': 'Preflight' } // Activity type icons @@ -16,7 +18,8 @@ const typeIcons: Record = { 'tool_call': '🔧', 'policy_decision': '🛡️', 'quarantine_change': '⚠️', - 'server_change': '🔄' + 'server_change': '🔄', + 'preflight': '🛫' } // Status labels @@ -93,6 +96,154 @@ export const getIntentBadgeClass = (operationType: string): string => { return intentClasses[operationType] || 'badge-ghost' } +// --- Spec 098: preflight activity records ----------------------------------- +// +// A preflight record is set-scoped, not server-scoped: `server_name` and +// `tool_name` are empty by construction and everything an operator wants to see +// lives in `metadata`: +// {verdict, ids_count, reasons: {code: count}, per_tool: [{id,status,reason?}]} +// (written by runtime.ActivityService.RecordPreflight). Without the helpers +// below the row renders as three empty columns, which is exactly the +// transparency gap FR-014 exists to close. + +/** One {reason code, count} pair of the metadata rollup. */ +export interface PreflightReasonCount { + reason: string + count: number +} + +/** One line of the per-tool detail. `reason` is absent for a ready tool. */ +export interface PreflightPerToolEntry { + id: string + status: string + reason?: string +} + +/** + * How many distinct reason codes the one-line summary names before it collapses + * the tail into "+N more". A preflight may carry up to 100 ids across 15 reason + * codes; an uncapped rollup would push every other column off screen. + */ +const MAX_PREFLIGHT_SUMMARY_REASONS = 3 + +/** True for a Spec 098 preflight activity record. */ +export const isPreflightActivity = (activity?: { type?: string } | null): boolean => + activity?.type === 'preflight' + +/** + * Read `metadata.reasons` into a DETERMINISTIC order: most frequent first, ties + * broken alphabetically. Object key order is insertion-dependent, so without the + * sort two renders of the same record could disagree. + */ +export const preflightReasonRollup = ( + metadata?: Record | null +): PreflightReasonCount[] => { + const counts = new Map() + + const reasons = metadata?.reasons + if (reasons && typeof reasons === 'object' && !Array.isArray(reasons)) { + for (const [reason, count] of Object.entries(reasons as Record)) { + counts.set(reason, Number(count) || 0) + } + } + + // Fallback for a record whose rollup is missing: recount from the per-tool + // detail, which carries the same codes. + if (counts.size === 0) { + for (const tool of preflightPerTool(metadata)) { + if (tool.reason) counts.set(tool.reason, (counts.get(tool.reason) ?? 0) + 1) + } + } + + return Array.from(counts, ([reason, count]) => ({ reason, count })) + .sort((a, b) => (b.count - a.count) || a.reason.localeCompare(b.reason)) +} + +/** + * Render a rollup as "code xN, code xN". `limit <= 0` names them all; a positive + * limit collapses the tail into "+N more". + */ +export const formatPreflightReasons = ( + rollup: PreflightReasonCount[], + limit = 0 +): string => { + if (rollup.length === 0) return '' + + const shown = limit > 0 ? rollup.slice(0, limit) : rollup + const remaining = rollup.length - shown.length + const parts = shown.map(entry => `${entry.reason} x${entry.count}`) + if (remaining > 0) parts.push(`+${remaining} more`) + return parts.join(', ') +} + +/** Ordered per-tool detail; malformed entries are dropped, never rendered. */ +export const preflightPerTool = ( + metadata?: Record | null +): PreflightPerToolEntry[] => { + const perTool = metadata?.per_tool + if (!Array.isArray(perTool)) return [] + + return perTool + .filter((entry): entry is Record => + Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry)) + .map(entry => { + const line: PreflightPerToolEntry = { + id: String(entry.id ?? ''), + status: String(entry.status ?? ''), + } + if (entry.reason) line.reason = String(entry.reason) + return line + }) +} + +/** + * Number of unique tool ids the run evaluated. `ids_count` is authoritative; + * the per-tool length is the fallback for a partially-written record. + */ +export const preflightIdsCount = (metadata?: Record | null): number => { + const count = Number(metadata?.ids_count) + if (Number.isFinite(count) && count > 0) return count + return preflightPerTool(metadata).length +} + +/** + * One-line verdict summary for the activity table, e.g. + * "blocked (4 tools): server_disabled x2, tool_changed x1". + * Empty string when the record carries no readable verdict, so the caller can + * fall back to its usual placeholder. + */ +export const formatPreflightSummary = (metadata?: Record | null): string => { + const verdict = metadata?.verdict + if (!verdict || typeof verdict !== 'string') return '' + + const count = preflightIdsCount(metadata) + const summary = `${verdict} (${count} ${count === 1 ? 'tool' : 'tools'})` + const reasons = formatPreflightReasons( + preflightReasonRollup(metadata), + MAX_PREFLIGHT_SUMMARY_REASONS + ) + return reasons ? `${summary}: ${reasons}` : summary +} + +/** + * Badge class for a set-level verdict. `ready` is the only success; the rest are + * an operator action (blocked/unknown_ids) or a retry (degraded_retryable). + */ +export const getPreflightVerdictBadgeClass = (verdict?: string): string => { + switch (verdict) { + case 'ready': + return 'badge-success' + case 'degraded_retryable': + return 'badge-info' + case 'blocked': + return 'badge-warning' + case 'unknown_ids': + return 'badge-error' + default: + return 'badge-ghost' + } +} + /** * Format timestamp for display */ diff --git a/frontend/src/views/Activity.vue b/frontend/src/views/Activity.vue index c3c7754b..0e01f1ec 100644 --- a/frontend/src/views/Activity.vue +++ b/frontend/src/views/Activity.vue @@ -388,6 +388,18 @@ {{ activity.tool_name }} + + + {{ formatPreflightSummary(activity.metadata) }} + {{ activity.metadata.action }} @@ -606,8 +618,65 @@ - -
+ +
+

+ 🛫 + Preflight Verdict +

+
+
+ Verdict: + + {{ selectedActivity.metadata?.verdict || 'unknown' }} + + + {{ preflightIdsCount(selectedActivity.metadata) }} tool(s) checked + +
+
+ Reasons: + + {{ entry.reason }} x{{ entry.count }} + +
+
+ Tools: +
+ + {{ tool.status }} + + {{ tool.id }} + {{ tool.reason }} +
+
+
+
+ + +

@@ -712,6 +781,16 @@ import { matchesSessionFilter, resolveSessionFilter, } from '@/utils/sessionGrouping' +// Spec 098: preflight records carry their verdict in metadata; the renderers +// are shared (and unit-tested) rather than re-derived in the template. +import { + formatPreflightSummary, + getPreflightVerdictBadgeClass, + isPreflightActivity, + preflightIdsCount, + preflightPerTool, + preflightReasonRollup, +} from '@/utils/activity' import JsonViewer from '@/components/JsonViewer.vue' const route = useRoute() @@ -748,6 +827,8 @@ const activityTypes = [ { value: 'policy_decision', label: 'Policy Decision', icon: '🛡️' }, { value: 'quarantine_change', label: 'Quarantine Change', icon: '⚠️' }, { value: 'server_change', label: 'Server Change', icon: '🔄' }, + // Spec 098: required-tools preflight (set-scoped record). + { value: 'preflight', label: 'Preflight', icon: '🛫' }, ] // Pagination @@ -1197,7 +1278,9 @@ const formatType = (type: string): string => { 'config_change': 'Config Change', 'policy_decision': 'Policy Decision', 'quarantine_change': 'Quarantine Change', - 'server_change': 'Server Change' + 'server_change': 'Server Change', + // Spec 098 + 'preflight': 'Preflight' } return typeLabels[type] || type } @@ -1212,7 +1295,9 @@ const getTypeIcon = (type: string): string => { 'config_change': '⚡', 'policy_decision': '🛡️', 'quarantine_change': '⚠️', - 'server_change': '🔄' + 'server_change': '🔄', + // Spec 098 + 'preflight': '🛫' } return typeIcons[type] || '📋' } diff --git a/frontend/tests/unit/activity-preflight.spec.ts b/frontend/tests/unit/activity-preflight.spec.ts new file mode 100644 index 00000000..dfea4415 --- /dev/null +++ b/frontend/tests/unit/activity-preflight.spec.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest' +import { + formatType, + getTypeIcon, + formatPreflightSummary, + preflightReasonRollup, + preflightPerTool, + isPreflightActivity, +} from '../../src/utils/activity' + +/** + * Spec 098 (T023) — the Web UI activity view must render a `preflight` record + * with its verdict instead of an empty row (US3 acceptance 3). + * + * A preflight is set-scoped: server_name and tool_name are empty by + * construction and everything readable lives in `metadata` + * ({verdict, ids_count, reasons{code:count}, per_tool[{id,status,reason?}]}). + */ + +const blockedMetadata = { + verdict: 'blocked', + ids_count: 4, + reasons: { server_disabled: 2, tool_changed: 1 }, + per_tool: [ + { id: 'ctl:echo', status: 'ready' }, + { id: 'gh:sync', status: 'unavailable', reason: 'server_disabled' }, + { id: 'gh:close', status: 'unavailable', reason: 'server_disabled' }, + { id: 'slack:post', status: 'unavailable', reason: 'tool_changed' }, + ], +} + +describe('preflight activity type', () => { + it('labels the preflight type', () => { + expect(formatType('preflight')).toBe('Preflight') + }) + + it('gives the preflight type its own icon', () => { + expect(getTypeIcon('preflight')).toBe('🛫') + expect(getTypeIcon('preflight')).not.toBe(getTypeIcon('unknown_type')) + }) + + it('recognises preflight records', () => { + expect(isPreflightActivity({ type: 'preflight' })).toBe(true) + expect(isPreflightActivity({ type: 'tool_call' })).toBe(false) + expect(isPreflightActivity(null)).toBe(false) + }) +}) + +describe('preflightReasonRollup', () => { + it('orders by count desc, then reason asc, for a stable line', () => { + expect(preflightReasonRollup(blockedMetadata)).toEqual([ + { reason: 'server_disabled', count: 2 }, + { reason: 'tool_changed', count: 1 }, + ]) + + expect( + preflightReasonRollup({ reasons: { server_disabled: 1, hash_mismatch: 1, not_found: 1 } }) + ).toEqual([ + { reason: 'hash_mismatch', count: 1 }, + { reason: 'not_found', count: 1 }, + { reason: 'server_disabled', count: 1 }, + ]) + }) + + it('is empty for a record without reasons', () => { + expect(preflightReasonRollup({ verdict: 'ready' })).toEqual([]) + expect(preflightReasonRollup(undefined)).toEqual([]) + }) +}) + +describe('formatPreflightSummary', () => { + it('names the verdict, the count and the reason rollup', () => { + expect(formatPreflightSummary(blockedMetadata)).toBe( + 'blocked (4 tools): server_disabled x2, tool_changed x1' + ) + }) + + it('renders an all-ready run without reasons', () => { + expect(formatPreflightSummary({ verdict: 'ready', ids_count: 2, reasons: {} })).toBe( + 'ready (2 tools)' + ) + }) + + it('does not pluralize a single tool', () => { + expect(formatPreflightSummary({ verdict: 'ready', ids_count: 1 })).toBe('ready (1 tool)') + }) + + it('caps the rollup so a 100-id run cannot blow up the column', () => { + const summary = formatPreflightSummary({ + verdict: 'blocked', + ids_count: 5, + reasons: { + server_disabled: 1, + tool_changed: 1, + not_found: 1, + hash_mismatch: 1, + oauth_required: 1, + }, + }) + expect(summary).toContain('+2 more') + expect(summary.match(/ x1/g)).toHaveLength(3) + }) + + it('falls back to per_tool length when ids_count is absent', () => { + expect( + formatPreflightSummary({ + verdict: 'unknown_ids', + per_tool: [{ id: 'a:1', status: 'unavailable', reason: 'not_found' }], + }) + ).toBe('unknown_ids (1 tool): not_found x1') + }) + + it('is empty for metadata that carries no verdict', () => { + expect(formatPreflightSummary({})).toBe('') + expect(formatPreflightSummary(undefined)).toBe('') + expect(formatPreflightSummary({ intent: { operation_type: 'read' } })).toBe('') + }) +}) + +describe('preflightPerTool', () => { + it('keeps the requested order and carries the reason only when unavailable', () => { + const perTool = preflightPerTool(blockedMetadata) + expect(perTool.map(t => t.id)).toEqual(['ctl:echo', 'gh:sync', 'gh:close', 'slack:post']) + expect(perTool[0].reason).toBeUndefined() + expect(perTool[1].reason).toBe('server_disabled') + }) + + it('tolerates a record without per-tool detail', () => { + expect(preflightPerTool({ verdict: 'ready' })).toEqual([]) + expect(preflightPerTool(undefined)).toEqual([]) + }) + + it('drops malformed entries instead of rendering undefined rows', () => { + expect( + preflightPerTool({ per_tool: ['nope', null, { id: 'ok:1', status: 'ready' }] }) + ).toEqual([{ id: 'ok:1', status: 'ready' }]) + }) +}) diff --git a/internal/cliclient/client.go b/internal/cliclient/client.go index 13b4013a..0b9677fa 100644 --- a/internal/cliclient/client.go +++ b/internal/cliclient/client.go @@ -2194,3 +2194,57 @@ func (c *Client) EditRegistrySource(ctx context.Context, id, name, sourceURL, se } return &apiResp.Data.Registry, nil } + +// Preflight runs a required-tools preflight against the daemon (Spec 098). +// +// The verdict is DATA, not an error: a 200 carrying `blocked` comes back as a +// response with no error, and the caller turns it into an exit code. Only a +// request the daemon refused (400/503) or a transport failure is an error here. +func (c *Client) Preflight(ctx context.Context, request *contracts.PreflightRequest) (*contracts.PreflightResponse, error) { + url := fmt.Sprintf("%s/api/v1/preflight", c.baseURL) + + bodyBytes, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + c.prepareRequest(ctx, req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call preflight API: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var apiResp struct { + Success bool `json:"success"` + Data *contracts.PreflightResponse `json:"data"` + Error string `json:"error"` + RequestID string `json:"request_id"` + } + if err := json.Unmarshal(respBytes, &apiResp); err != nil { + return nil, fmt.Errorf("failed to parse response (status %d): %s", resp.StatusCode, string(respBytes)) + } + + if !apiResp.Success || resp.StatusCode != http.StatusOK { + msg := apiResp.Error + if msg == "" { + msg = fmt.Sprintf("API returned status %d", resp.StatusCode) + } + return nil, parseAPIError(msg, apiResp.RequestID) + } + if apiResp.Data == nil { + return nil, fmt.Errorf("daemon returned success with no preflight data") + } + return apiResp.Data, nil +} diff --git a/internal/httpapi/activity.go b/internal/httpapi/activity.go index e04c5c39..7f59b4d3 100644 --- a/internal/httpapi/activity.go +++ b/internal/httpapi/activity.go @@ -127,7 +127,7 @@ func parseActivityFilters(r *http.Request) storage.ActivityFilter { // @Tags Activity // @Accept json // @Produce json -// @Param type query string false "Filter by activity type(s), comma-separated for multiple (Spec 024)" Enums(tool_call, policy_decision, quarantine_change, server_change, system_start, system_stop, internal_tool_call, config_change) +// @Param type query string false "Filter by activity type(s), comma-separated for multiple (Spec 024)" Enums(tool_call, policy_decision, quarantine_change, server_change, system_start, system_stop, internal_tool_call, config_change, preflight) // @Param server query string false "Filter by server name" // @Param tool query string false "Filter by tool name" // @Param session_id query string false "Filter by MCP transport session ID" diff --git a/internal/httpapi/auth_profile_pin_test.go b/internal/httpapi/auth_profile_pin_test.go new file mode 100644 index 00000000..a7504806 --- /dev/null +++ b/internal/httpapi/auth_profile_pin_test.go @@ -0,0 +1,99 @@ +package httpapi + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "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/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" +) + +// Spec 098 T010: the REST auth path used to build the agent AuthContext by +// hand and omit ProfilePin, so a profile-pinned token was evaluated against the +// unpinned server set. The pin must reach the request context, because the +// preflight evaluation scope is token scope ∩ token pin ∩ requested profile. +func TestAPIKeyAuth_AgentToken_PropagatesProfilePin(t *testing.T) { + logger := zap.NewNop().Sugar() + tmpDir := t.TempDir() + _, err := auth.GetOrCreateHMACKey(tmpDir) + require.NoError(t, err) + + rawToken, err := auth.GenerateToken() + require.NoError(t, err) + + agentToken := &auth.AgentToken{ + Name: "pinned-agent", + TokenPrefix: auth.TokenPrefix(rawToken), + AllowedServers: []string{"github", "fs"}, + Permissions: []string{auth.PermRead}, + ProfilePin: "work", + ExpiresAt: time.Now().Add(24 * time.Hour), + } + + store := &testTokenStore{ + validateFunc: func(token string, _ []byte) (*auth.AgentToken, error) { + if token == rawToken { + return agentToken, nil + } + return nil, fmt.Errorf("token not found") + }, + } + + srv := NewServer(&testControllerWithConfig{cfg: &config.Config{APIKey: "admin-key"}}, logger, nil) + srv.SetTokenStore(store, tmpDir) + + var capturedCtx *auth.AuthContext + handler := srv.apiKeyAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedCtx = auth.AuthContextFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/test", nil) + req.Header.Set("X-API-Key", rawToken) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.NotNil(t, capturedCtx) + assert.Equal(t, "work", capturedCtx.ProfilePin, "the token's profile pin must survive REST authentication") + assert.Equal(t, []string{"github", "fs"}, capturedCtx.AllowedServers) +} + +// The end-to-end consequence: with the pin propagated, the evaluation scope is +// the intersection of the three restrictions — never wider than the pin. +func TestAgentTokenScope_IntersectionUsesPropagatedPin(t *testing.T) { + authCtx := (&auth.AgentToken{ + Name: "pinned-agent", + AllowedServers: []string{"github", "fs", "jira"}, + ProfilePin: "work", + }).AuthContext() + + scope := preflight.ResolveScope(preflight.ScopeInputs{ + TokenServers: authCtx.AllowedServers, + TokenPinName: authCtx.ProfilePin, + TokenPinServers: []string{"github", "jira"}, + RequestedProfileName: "review", + RequestedProfileServers: []string{"github", "fs"}, + }) + + assert.True(t, scope.Allows("github")) + assert.False(t, scope.Allows("fs"), "excluded by the token pin") + assert.False(t, scope.Allows("jira"), "excluded by the requested profile") + + // Dropping the pin (the pre-fix behavior) would have widened the scope — + // this is the regression the propagation prevents. + unpinned := preflight.ResolveScope(preflight.ScopeInputs{ + TokenServers: authCtx.AllowedServers, + RequestedProfileName: "review", + RequestedProfileServers: []string{"github", "fs"}, + }) + assert.True(t, unpinned.Allows("fs"), "sanity: without the pin, fs would have been in scope") +} diff --git a/internal/httpapi/contracts_test.go b/internal/httpapi/contracts_test.go index efb0e4e8..a7ca9f3a 100644 --- a/internal/httpapi/contracts_test.go +++ b/internal/httpapi/contracts_test.go @@ -16,6 +16,7 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" internalRuntime "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" "github.com/smart-mcp-proxy/mcpproxy-go/internal/secret" "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" @@ -373,6 +374,10 @@ func (m *MockServerController) GetToolApproval(_, _ string) (*storage.ToolApprov return nil, nil } func (m *MockServerController) GetToolApprovalStatus(_, _ string) (string, error) { return "", nil } +func (m *MockServerController) RunPreflight(_ context.Context, _ preflight.Params) (preflight.Outcome, error) { + return preflight.Outcome{}, nil +} +func (m *MockServerController) RecordPreflight(_ internalRuntime.PreflightActivity) error { return nil } func (m *MockServerController) GetOnboardingState() (*storage.OnboardingState, error) { return &storage.OnboardingState{}, nil } diff --git a/internal/httpapi/preflight.go b/internal/httpapi/preflight.go new file mode 100644 index 00000000..eaec4b22 --- /dev/null +++ b/internal/httpapi/preflight.go @@ -0,0 +1,399 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext" + internalRuntime "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/toolannotations" +) + +// Spec 098 — POST /api/v1/preflight. +// +// The contract this file implements, in one place because the pieces are easy +// to break independently: +// +// - HTTP status reports whether the CHECK executed, never what it found. A +// fully blocked set is a 200 carrying verdict "blocked"; only a malformed +// request (400) or a proxy that cannot answer honestly (503) is non-200. +// - The activity record is written SYNCHRONOUSLY before the 200 (FR-014). A +// failed write is a 503: a preflight nobody can audit afterwards breaks the +// transparency guarantee the feature exists to provide. +// - Rejected requests (400/503) execute no preflight and therefore write no +// record. +const ( + // preflightMaxTools bounds the RAW tools array, before dedup (FR-008): the + // limit is about request size, not about how much work the evaluator does. + preflightMaxTools = 100 + // preflightMaxWaitMS is the wait_ms cap. Over it is a 400 rather than a + // silent clamp, so a caller asking for a 60s wait learns that it will not + // get one. + preflightMaxWaitMS = 10000 + // preflightPollFloor is the minimum interval between re-evaluations during + // a wait (FR-012). Waiting must add no meaningful load. + preflightPollFloor = 250 * time.Millisecond + // preflightWaitSlots is the dedicated wait budget: the number of preflights + // that may be parked in their poll loop at once. Small and fixed — a flood + // of waiting preflights must not tie up the HTTP server, and spec 093's + // admission control is scoped to upstream tool calls, not to this. + preflightWaitSlots = 4 +) + +// preflightValidationError is a caller-input error: it becomes a 400 with its +// own message, which names the exact rule that was broken. +type preflightValidationError struct { + message string +} + +func (e *preflightValidationError) Error() string { return e.message } + +func newPreflightValidationError(format string, args ...interface{}) *preflightValidationError { + return &preflightValidationError{message: fmt.Sprintf(format, args...)} +} + +// normalizePreflightTools validates the raw tools array and deduplicates it, +// preserving first-occurrence order (FR-008). +// +// Deduplication is by id. Two entries for the same id carrying different +// pin_hash values are a validation error rather than a "last one wins": the +// request states two incompatible expectations about the same tool and guessing +// which one the caller meant would silently answer a question they did not ask. +// An omitted pin counts as a value here — {id} and {id, pin} disagree about +// whether the tool is pinned at all. +func normalizePreflightTools(raw []contracts.PreflightToolRef) ([]preflight.ToolRef, error) { + if len(raw) == 0 { + return nil, newPreflightValidationError("tools must contain at least one entry: an empty preflight is a caller bug, not a trivially-green check") + } + if len(raw) > preflightMaxTools { + return nil, newPreflightValidationError("tools contains %d entries, which exceeds the limit of %d per request", len(raw), preflightMaxTools) + } + + seen := make(map[string]int, len(raw)) + out := make([]preflight.ToolRef, 0, len(raw)) + for _, ref := range raw { + id := strings.TrimSpace(ref.ID) + pin := strings.TrimSpace(ref.PinHash) + if idx, ok := seen[id]; ok { + if out[idx].PinHash != pin { + return nil, newPreflightValidationError("duplicate tool id %q carries conflicting pin_hash values (%q and %q)", id, out[idx].PinHash, pin) + } + continue + } + seen[id] = len(out) + out = append(out, preflight.ToolRef{ID: id, PinHash: pin}) + } + return out, nil +} + +// validatePreflightWait enforces the wait_ms range (FR-012 cap). +func validatePreflightWait(waitMS int) error { + if waitMS < 0 { + return newPreflightValidationError("wait_ms must not be negative") + } + if waitMS > preflightMaxWaitMS { + return newPreflightValidationError("wait_ms is %d, which exceeds the cap of %d", waitMS, preflightMaxWaitMS) + } + return nil +} + +// preflightParams turns the authenticated request plus its validated body into +// the evaluator's parameters. +// +// Tier detection is the security-relevant half (FR-013): everything the auth +// middleware authenticated as admin — API key over TCP, the Unix socket, the +// Windows named pipe — is the operator tier; an agent token is the scoped tier +// and carries its allowed_servers and profile pin into the evaluation. Tier is +// always set explicitly: an empty Tier reads as operator to the evaluator, so a +// call site that forgets it would silently get the more permissive disclosure. +func preflightParams(r *http.Request, req *contracts.PreflightRequest, tools []preflight.ToolRef) preflight.Params { + params := preflight.Params{ + Tools: tools, + Tier: preflight.TierOperator, + Profile: strings.TrimSpace(req.Profile), + } + if req.Policy != nil { + params.Filters = toolannotations.Filters{ + ReadOnlyOnly: req.Policy.ReadOnlyOnly, + ExcludeDestructive: req.Policy.ExcludeDestructive, + ExcludeOpenWorld: req.Policy.ExcludeOpenWorld, + } + } + if tier, authCtx := disclosureTier(r); tier == preflight.TierAgentToken { + params.Tier = preflight.TierAgentToken + params.TokenServers = authCtx.AllowedServers + params.TokenProfilePin = authCtx.ProfilePin + } + return params +} + +// disclosureTier maps an authenticated request to the Spec 098 disclosure tier. +// It is the single source of that mapping: preflight uses it for the evaluator +// tier, and the tool-listing endpoints use it to decide whether a tool's hash +// pin may be published (T020, FR-011 + FR-013). The returned AuthContext is +// non-nil only for the agent-token tier, whose scope the caller needs. +func disclosureTier(r *http.Request) (preflight.Tier, *auth.AuthContext) { + if authCtx := auth.AuthContextFromContext(r.Context()); authCtx != nil && authCtx.Type == auth.AuthTypeAgent { + return preflight.TierAgentToken, authCtx + } + return preflight.TierOperator, nil +} + +// handlePreflight handles POST /api/v1/preflight +// @Summary Preflight required tools +// @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. +// @Tags tools +// @Accept json +// @Produce json +// @Param request body contracts.PreflightRequest true "Tool IDs (1-100 before dedup), optional profile, annotation policy filters and wait budget" +// @Success 200 {object} contracts.APIResponse{data=contracts.PreflightResponse} "Preflight verdict and per-tool results" +// @Failure 400 {object} contracts.APIResponse "Validation error (empty or oversized tool list, conflicting duplicate pins, unknown profile, wait_ms out of range)" +// @Failure 401 {object} contracts.APIResponse "Missing or invalid credentials" +// @Failure 503 {object} contracts.APIResponse "Runtime unavailable, evaluator infrastructure read failure, or the activity record could not be persisted" +// @Security ApiKeyHeader +// @Security ApiKeyQuery +// @Router /api/v1/preflight [post] +func (s *Server) handlePreflight(w http.ResponseWriter, r *http.Request) { + var req contracts.PreflightRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.writeError(w, r, http.StatusBadRequest, "Invalid JSON payload") + return + } + + tools, err := normalizePreflightTools(req.Tools) + if err != nil { + s.writeError(w, r, http.StatusBadRequest, err.Error()) + return + } + if err := validatePreflightWait(req.WaitMS); err != nil { + s.writeError(w, r, http.StatusBadRequest, err.Error()) + return + } + + params := preflightParams(r, &req, tools) + + outcome, waitedMS, err := s.runPreflightWithWait(r.Context(), params, req.WaitMS) + if err != nil { + switch { + case errors.Is(err, preflight.ErrUnknownProfile): + // A profile the config does not define is a caller mistake, not + // proxy state: inventing a verdict for it would put a request bug + // into the reason taxonomy. + s.writeError(w, r, http.StatusBadRequest, fmt.Sprintf("Unknown profile %q", strings.TrimSpace(req.Profile))) + case errors.Is(err, preflight.ErrRuntimeUnavailable): + s.writeError(w, r, http.StatusServiceUnavailable, "Preflight unavailable: the proxy runtime is not ready to evaluate") + default: + // An index/storage/snapshot read failed. Reduced-fidelity verdicts + // are worse than no verdict: the caller would gate a pipeline on a + // guess (FR-006). + s.getRequestLogger(r).Errorw("Preflight evaluation failed", "error", err) + s.writeError(w, r, http.StatusServiceUnavailable, "Preflight unavailable: local state could not be read") + } + return + } + + response := preflightResponse(outcome, req.WaitMS, waitedMS) + + // FR-014: durable BEFORE the 200. This is the whole reason RecordPreflight + // is synchronous and returns its error instead of going through the bounded + // async activity channel, which drops under load. + if err := s.controller.RecordPreflight(preflightActivityRecord(r, outcome)); err != nil { + s.getRequestLogger(r).Errorw("Preflight activity record could not be persisted", "error", err) + s.writeError(w, r, http.StatusServiceUnavailable, "Preflight unavailable: the activity record could not be persisted") + return + } + + s.writeSuccess(w, response) +} + +// runPreflightWithWait evaluates once and, when a wait budget was requested and +// every current failure is retryable, keeps re-evaluating local state until the +// set is ready, a non-retryable failure appears, or the deadline passes +// (FR-012). It returns the final outcome and the milliseconds actually waited. +// +// It never blocks indefinitely and never queues: with the wait budget exhausted +// the request degrades to an immediate answer with waited_ms 0. +func (s *Server) runPreflightWithWait(ctx context.Context, params preflight.Params, waitMS int) (preflight.Outcome, int, error) { + outcome, err := s.controller.RunPreflight(ctx, params) + if err != nil || waitMS <= 0 { + return outcome, 0, err + } + // Nothing to wait for: the set is ready, or it is blocked by something that + // waiting cannot fix. Both terminate before a single sleep. + if outcome.Verdict != preflight.VerdictDegradedRetryable { + return outcome, 0, nil + } + if !s.acquirePreflightWaitSlot() { + // Graceful degradation, not an error and not a queue: the caller gets + // the current verdict and waited_ms 0, and can retry. + return outcome, 0, nil + } + defer s.releasePreflightWaitSlot() + + interval := s.preflightPollInterval() + start := time.Now() + deadline := start.Add(time.Duration(waitMS) * time.Millisecond) + timer := time.NewTimer(interval) + defer timer.Stop() + + for { + remaining := time.Until(deadline) + if remaining <= 0 { + break + } + sleep := interval + if sleep > remaining { + sleep = remaining + } + timer.Reset(sleep) + select { + case <-ctx.Done(): + // The caller went away. Resolve with what we have rather than + // manufacturing an error — "always resolves" (FR-012). + return outcome, elapsedMS(start), nil + case <-timer.C: + } + + next, err := s.controller.RunPreflight(ctx, params) + if err != nil { + return preflight.Outcome{}, elapsedMS(start), err + } + outcome = next + if outcome.Verdict != preflight.VerdictDegradedRetryable { + break + } + } + return outcome, elapsedMS(start), nil +} + +func elapsedMS(start time.Time) int { + ms := int(time.Since(start).Round(time.Millisecond) / time.Millisecond) + if ms < 0 { + return 0 + } + return ms +} + +// preflightPollInterval is the re-evaluation interval, floored at 250 ms +// (FR-012). Tests lower it via preflightPollOverride; production never does. +func (s *Server) preflightPollInterval() time.Duration { + if s.preflightPollOverride > 0 { + return s.preflightPollOverride + } + return preflightPollFloor +} + +// acquirePreflightWaitSlot takes one of the dedicated wait slots without +// blocking. A nil semaphore (a Server built without NewServer) reports +// exhausted, which degrades to "answer immediately" — the safe direction. +func (s *Server) acquirePreflightWaitSlot() bool { + if s.preflightWaitSem == nil { + return false + } + select { + case s.preflightWaitSem <- struct{}{}: + return true + default: + return false + } +} + +func (s *Server) releasePreflightWaitSlot() { + if s.preflightWaitSem == nil { + return + } + select { + case <-s.preflightWaitSem: + default: + } +} + +// preflightResponse serializes one outcome. waited_ms is present whenever a +// wait was REQUESTED — including the 0 that says "the wait budget was exhausted +// (or nothing was worth waiting for), here is the current state" — and absent +// when no wait was asked for at all. +func preflightResponse(outcome preflight.Outcome, waitMS, waitedMS int) contracts.PreflightResponse { + response := contracts.PreflightResponse{ + Verdict: outcome.Verdict, + CheckedAt: time.Now().UTC(), + Tools: make([]contracts.PreflightToolResult, 0, len(outcome.Results)), + } + if response.Verdict == "" { + response.Verdict = preflight.VerdictReady + } + if waitMS > 0 { + waited := waitedMS + response.WaitedMS = &waited + } + for i := range outcome.Results { + result := outcome.Results[i] + entry := contracts.PreflightToolResult{ + ID: result.ID, + Status: result.Status, + Hash: result.Hash, + DidYouMean: result.DidYouMean, + } + // A ready result carries no failure fields at all — `ready` is a + // status, not a reason, and an emitted `retryable: false` on a ready + // tool would read as a failure. + if result.Status != preflight.StatusReady { + retryable := result.Retryable + entry.Reason = result.Reason + entry.Retryable = &retryable + entry.Action = result.Action + entry.Detail = result.Detail + entry.Remediation = result.Remediation + } + response.Tools = append(response.Tools, entry) + } + return response +} + +// preflightActivityRecord builds the FR-014 payload: enum codes, counts and +// tool ids only. No descriptions, no arguments, no hashes. +func preflightActivityRecord(r *http.Request, outcome preflight.Outcome) internalRuntime.PreflightActivity { + record := internalRuntime.PreflightActivity{ + RequestID: reqcontext.GetRequestID(r.Context()), + Verdict: outcome.Verdict, + Source: preflightActivitySource(r), + Tools: make([]internalRuntime.PreflightToolOutcome, 0, len(outcome.Results)), + } + if record.Verdict == "" { + record.Verdict = preflight.VerdictReady + } + if authCtx := auth.AuthContextFromContext(r.Context()); authCtx != nil { + record.UserID = authCtx.UserID + record.UserEmail = authCtx.Email + } + for i := range outcome.Results { + result := outcome.Results[i] + outcomeEntry := internalRuntime.PreflightToolOutcome{ + ID: result.ID, + Status: result.Status, + } + if result.Status != preflight.StatusReady { + outcomeEntry.Reason = result.Reason + } + record.Tools = append(record.Tools, outcomeEntry) + } + return record +} + +// preflightActivitySource attributes the record to the surface that made the +// call, so a cron job's preflight is distinguishable from a Web-UI one. The +// CLI announces itself with the same client header tool calls use. +func preflightActivitySource(r *http.Request) storage.ActivitySource { + if strings.HasPrefix(strings.ToLower(r.Header.Get(XMCPProxyClientHeader)), "cli/") { + return storage.ActivitySourceCLI + } + return storage.ActivitySourceAPI +} diff --git a/internal/httpapi/preflight_bench_test.go b/internal/httpapi/preflight_bench_test.go new file mode 100644 index 00000000..dcb8d407 --- /dev/null +++ b/internal/httpapi/preflight_bench_test.go @@ -0,0 +1,216 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" + internalRuntime "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" +) + +// Spec 098 SC-002, normative measurement: "a preflight of 10 tools completes in +// under 50 ms (p95) … at the handler level (evaluator + response + +// activity-record build, excluding HTTP transport)". +// +// What is inside the measured loop: request decode, validation + dedup, tier +// detection, a REAL evaluation over in-memory readers, response construction, +// JSON encoding of the envelope, and the activity-record build. What is +// deliberately outside: the network, chi routing and auth middleware (excluded +// by calling the handler directly), and the BBolt write behind RecordPreflight +// (a storage cost, measured where storage is measured). +// +// The assertion is a ceiling with two orders of magnitude of headroom rather +// than the 50 ms budget itself: it fails on an architectural regression (an +// upstream call, a full index scan) and cannot flake on a loaded CI runner. +const preflightBenchPerOpCeiling = 250 * time.Millisecond + +// --- in-memory evaluator world --------------------------------------------- + +type benchIndex struct { + tools map[string][]preflight.IndexedTool + servers []string +} + +func (b *benchIndex) ToolsByServer(serverName string) ([]preflight.IndexedTool, error) { + return b.tools[serverName], nil +} + +func (b *benchIndex) IndexedServerNames() ([]string, error) { return b.servers, nil } + +type benchApprovals struct { + records map[string]*preflight.ApprovalState +} + +func (b *benchApprovals) ToolApproval(serverName, toolName string) (*preflight.ApprovalState, error) { + return b.records[serverName+":"+toolName], nil +} + +type benchState struct { + states map[string]preflight.ServerRuntime +} + +func (b *benchState) ServerRuntime(serverName string) (preflight.ServerRuntime, bool) { + rt, ok := b.states[serverName] + return rt, ok +} + +type benchPolicy struct { + servers map[string]preflight.ServerPolicy +} + +func (b *benchPolicy) ServerPolicy(serverName string) (preflight.ServerPolicy, error) { + return b.servers[serverName], nil +} + +func (b *benchPolicy) ToolConfigDenied(_, _ string) (bool, error) { return false, nil } +func (b *benchPolicy) QuarantineEnabled() bool { return true } + +// benchPreflightController runs the real evaluator over an in-memory world, so +// the benchmark measures the same code path a served preflight takes minus the +// database. +type benchPreflightController struct { + baseController + ec preflight.EvalContext +} + +func newBenchPreflightController(servers, toolsPerServer int) *benchPreflightController { + index := &benchIndex{tools: make(map[string][]preflight.IndexedTool, servers)} + approvals := &benchApprovals{records: make(map[string]*preflight.ApprovalState, servers*toolsPerServer)} + state := &benchState{states: make(map[string]preflight.ServerRuntime, servers)} + policy := &benchPolicy{servers: make(map[string]preflight.ServerPolicy, servers)} + + readOnly, destructive, openWorld := true, false, false + for s := 0; s < servers; s++ { + serverName := fmt.Sprintf("srv%02d", s) + index.servers = append(index.servers, serverName) + state.states[serverName] = preflight.ServerRuntime{State: preflight.RuntimeStateReady} + policy.servers[serverName] = preflight.ServerPolicy{Found: true, Enabled: true} + + tools := make([]preflight.IndexedTool, 0, toolsPerServer) + for i := 0; i < toolsPerServer; i++ { + toolID := fmt.Sprintf("%s:tool%02d", serverName, i) + tools = append(tools, preflight.IndexedTool{ + Name: toolID, + Annotations: &config.ToolAnnotations{ + ReadOnlyHint: &readOnly, + DestructiveHint: &destructive, + OpenWorldHint: &openWorld, + }, + }) + approvals.records[toolID] = &preflight.ApprovalState{ + Status: preflight.ApprovalStatusApproved, + CurrentHash: "abc123def456", + HashSchemaVersion: 2, + } + } + index.tools[serverName] = tools + } + + return &benchPreflightController{ + ec: preflight.EvalContext{ + Index: index, + Approvals: approvals, + State: state, + Policy: policy, + Tier: preflight.TierOperator, + }, + } +} + +func (c *benchPreflightController) GetCurrentConfig() interface{} { + return &config.Config{APIKey: preflightTestAPIKey} +} + +func (c *benchPreflightController) RunPreflight(ctx context.Context, params preflight.Params) (preflight.Outcome, error) { + ec := c.ec + ec.Tier = params.Tier + ec.Filters = params.Filters + results, err := preflight.Evaluate(ctx, ec, params.Tools) + if err != nil { + return preflight.Outcome{}, err + } + return preflight.Outcome{Verdict: preflight.VerdictForResults(results), Results: results}, nil +} + +// RecordPreflight accepts the built record and discards it: the record BUILD is +// what SC-002 counts, the BBolt write is storage's cost. +func (c *benchPreflightController) RecordPreflight(rec internalRuntime.PreflightActivity) error { + if len(rec.Tools) == 0 { + return fmt.Errorf("empty preflight activity record") + } + return nil +} + +func benchPreflightBody(b *testing.B, ids ...string) []byte { + b.Helper() + request := contracts.PreflightRequest{Tools: make([]contracts.PreflightToolRef, 0, len(ids))} + for _, id := range ids { + request.Tools = append(request.Tools, contracts.PreflightToolRef{ID: id}) + } + body, err := json.Marshal(request) + if err != nil { + b.Fatalf("marshal request: %v", err) + } + return body +} + +func benchPreflightIDs(n int) []string { + ids := make([]string, 0, n) + for i := 0; i < n; i++ { + ids = append(ids, fmt.Sprintf("srv%02d:tool%02d", i%10, i%5)) + } + return ids +} + +func runPreflightBenchmark(b *testing.B, ids []string) { + b.Helper() + srv := NewServer(newBenchPreflightController(10, 5), zap.NewNop().Sugar(), nil) + body := benchPreflightBody(b, ids...) + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + req := httptest.NewRequest(http.MethodPost, "/api/v1/preflight", bytes.NewReader(body)) + w := httptest.NewRecorder() + // The handler is called directly: SC-002 excludes HTTP transport, and + // routing/auth are measured by their own middlewares' tests. + srv.handlePreflight(w, req) + if w.Code != http.StatusOK { + b.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + } + b.StopTimer() + + if b.N > 0 { + if perOp := b.Elapsed() / time.Duration(b.N); perOp > preflightBenchPerOpCeiling { + b.Errorf("preflight took %v per op, over the %v ceiling — something in the path is doing I/O-scale work", perOp, preflightBenchPerOpCeiling) + } + } +} + +// BenchmarkPreflightHandler10Tools is the SC-002 shape. +func BenchmarkPreflightHandler10Tools(b *testing.B) { + runPreflightBenchmark(b, benchPreflightIDs(10)) +} + +// BenchmarkPreflightHandler100Tools is the largest legal request. +func BenchmarkPreflightHandler100Tools(b *testing.B) { + runPreflightBenchmark(b, benchPreflightIDs(100)) +} + +// BenchmarkPreflightHandlerMixed includes the diagnosis-heavy branches an +// unhappy cron run actually hits (unknown id + did_you_mean, unknown server). +func BenchmarkPreflightHandlerMixed(b *testing.B) { + ids := append(benchPreflightIDs(8), "srv00:missing", "ghost:tool00") + runPreflightBenchmark(b, ids) +} diff --git a/internal/httpapi/preflight_test.go b/internal/httpapi/preflight_test.go new file mode 100644 index 00000000..45c0223e --- /dev/null +++ b/internal/httpapi/preflight_test.go @@ -0,0 +1,685 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "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/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" + internalRuntime "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" +) + +const preflightTestAPIKey = "preflight-test-api-key" + +// preflightStep is one scripted answer from the controller's RunPreflight. A +// script lets the wait-loop tests describe a state that CHANGES between polls, +// which is the only thing wait_ms exists for. +type preflightStep struct { + outcome preflight.Outcome + err error +} + +type preflightController struct { + baseController + + mu sync.Mutex + script []preflightStep + last preflightStep + calls []preflight.Params + records []internalRuntime.PreflightActivity + + recordErr error +} + +func (c *preflightController) GetCurrentConfig() interface{} { + return &config.Config{APIKey: preflightTestAPIKey} +} + +func (c *preflightController) RunPreflight(_ context.Context, params preflight.Params) (preflight.Outcome, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.calls = append(c.calls, params) + step := c.last + if len(c.script) > 0 { + step = c.script[0] + if len(c.script) > 1 { + c.script = c.script[1:] + } else { + // The last scripted step repeats forever, so a deadline test does + // not have to guess how many polls will fit in the budget. + c.last = step + c.script = nil + } + } + return step.outcome, step.err +} + +func (c *preflightController) RecordPreflight(rec internalRuntime.PreflightActivity) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.recordErr != nil { + return c.recordErr + } + c.records = append(c.records, rec) + return nil +} + +func (c *preflightController) callCount() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.calls) +} + +func (c *preflightController) capturedParams() []preflight.Params { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]preflight.Params, len(c.calls)) + copy(out, c.calls) + return out +} + +func (c *preflightController) recordedActivity() []internalRuntime.PreflightActivity { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]internalRuntime.PreflightActivity, len(c.records)) + copy(out, c.records) + return out +} + +func readyOutcome(ids ...string) preflight.Outcome { + results := make([]preflight.Result, 0, len(ids)) + for _, id := range ids { + results = append(results, preflight.Result{ID: id, Status: preflight.StatusReady}) + } + return preflight.Outcome{Verdict: preflight.VerdictForResults(results), Results: results} +} + +func unavailableOutcome(id, reason string) preflight.Outcome { + results := []preflight.Result{{ + ID: id, + Status: preflight.StatusUnavailable, + Reason: reason, + Retryable: preflight.Retryable(reason), + Action: preflight.DefaultAction(reason), + Detail: "detail for " + id, + Remediation: preflight.DefaultRemediation(reason), + }} + return preflight.Outcome{Verdict: preflight.VerdictForResults(results), Results: results} +} + +func newPreflightServer(t *testing.T, ctrl *preflightController) *Server { + t.Helper() + return NewServer(ctrl, zap.NewNop().Sugar(), nil) +} + +// doPreflight posts a body (any JSON-marshalable value, or a raw string for the +// malformed-payload case) with the admin API key. +func doPreflight(t *testing.T, srv *Server, body interface{}) *httptest.ResponseRecorder { + t.Helper() + var payload []byte + switch typed := body.(type) { + case string: + payload = []byte(typed) + default: + var err error + payload, err = json.Marshal(typed) + require.NoError(t, err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/preflight", bytes.NewReader(payload)) + req.Header.Set("X-API-Key", preflightTestAPIKey) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + return w +} + +func decodePreflightResponse(t *testing.T, w *httptest.ResponseRecorder) contracts.PreflightResponse { + t.Helper() + var envelope struct { + Success bool `json:"success"` + Data contracts.PreflightResponse `json:"data"` + Error string `json:"error"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &envelope)) + require.True(t, envelope.Success, "expected a success envelope, got error %q", envelope.Error) + return envelope.Data +} + +func decodePreflightError(t *testing.T, w *httptest.ResponseRecorder) string { + t.Helper() + var envelope struct { + Success bool `json:"success"` + Error string `json:"error"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &envelope)) + assert.False(t, envelope.Success) + return envelope.Error +} + +// --- T015: happy path & envelope ------------------------------------------- + +func TestPreflight_ReadySetIs200WithStandardEnvelope(t *testing.T) { + ctrl := &preflightController{last: preflightStep{outcome: readyOutcome("ctl:echo", "ctl:add")}} + srv := newPreflightServer(t, ctrl) + + w := doPreflight(t, srv, contracts.PreflightRequest{ + Tools: []contracts.PreflightToolRef{{ID: "ctl:echo"}, {ID: "ctl:add"}}, + }) + + require.Equal(t, http.StatusOK, w.Code) + resp := decodePreflightResponse(t, w) + assert.Equal(t, preflight.VerdictReady, resp.Verdict) + assert.False(t, resp.CheckedAt.IsZero()) + assert.Nil(t, resp.WaitedMS, "waited_ms is absent when no wait was requested") + require.Len(t, resp.Tools, 2) + assert.Equal(t, "ctl:echo", resp.Tools[0].ID) + assert.Equal(t, preflight.StatusReady, resp.Tools[0].Status) + assert.Empty(t, resp.Tools[0].Reason) + assert.Nil(t, resp.Tools[0].Retryable, "a ready result carries no retryable flag") + assert.Empty(t, resp.Tools[0].Action) +} + +// A fully blocked set still executed the check, so it is a 200 carrying the +// verdict — HTTP status reports whether the check RAN, not what it found. +func TestPreflight_BlockedVerdictIsStill200(t *testing.T) { + ctrl := &preflightController{last: preflightStep{outcome: unavailableOutcome("ctl:echo", preflight.ReasonServerDisabled)}} + srv := newPreflightServer(t, ctrl) + + w := doPreflight(t, srv, contracts.PreflightRequest{Tools: []contracts.PreflightToolRef{{ID: "ctl:echo"}}}) + + require.Equal(t, http.StatusOK, w.Code) + resp := decodePreflightResponse(t, w) + assert.Equal(t, preflight.VerdictBlocked, resp.Verdict) + require.Len(t, resp.Tools, 1) + assert.Equal(t, preflight.ReasonServerDisabled, resp.Tools[0].Reason) + require.NotNil(t, resp.Tools[0].Retryable) + assert.False(t, *resp.Tools[0].Retryable) + assert.Equal(t, "enable", resp.Tools[0].Action) + assert.NotEmpty(t, resp.Tools[0].Remediation) +} + +// The endpoint sits inside the authenticated /api/v1 group: it discloses which +// tools exist and why they are unavailable, which is exactly the inventory an +// unauthenticated caller must not enumerate. +func TestPreflight_RequiresAuthentication(t *testing.T) { + ctrl := &preflightController{last: preflightStep{outcome: readyOutcome("ctl:echo")}} + srv := newPreflightServer(t, ctrl) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/preflight", + strings.NewReader(`{"tools":[{"id":"ctl:echo"}]}`)) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + require.Equal(t, http.StatusUnauthorized, w.Code) + assert.Zero(t, ctrl.callCount()) +} + +// --- T015: validation (every 400 rule) -------------------------------------- + +func TestPreflight_ValidationRejections(t *testing.T) { + oversized := make([]contracts.PreflightToolRef, 0, preflightMaxTools+1) + for i := 0; i <= preflightMaxTools; i++ { + // Every entry is the SAME id: dedup would collapse this to one tool, so + // a 400 here proves the cap applies to the raw array (FR-008). + oversized = append(oversized, contracts.PreflightToolRef{ID: "ctl:echo"}) + } + + tests := []struct { + name string + body interface{} + wantMessage string + }{ + { + name: "malformed json", + body: "{not json", + wantMessage: "Invalid JSON payload", + }, + { + name: "empty tool list", + body: contracts.PreflightRequest{}, + wantMessage: "at least one entry", + }, + { + name: "raw list over the limit", + body: contracts.PreflightRequest{Tools: oversized}, + wantMessage: "exceeds the limit of 100", + }, + { + name: "duplicate id with conflicting pins", + body: contracts.PreflightRequest{Tools: []contracts.PreflightToolRef{ + {ID: "ctl:echo", PinHash: "sha256/v1:aaaa"}, + {ID: "ctl:echo", PinHash: "sha256/v1:bbbb"}, + }}, + wantMessage: "conflicting pin_hash", + }, + { + name: "duplicate id where only one carries a pin", + body: contracts.PreflightRequest{Tools: []contracts.PreflightToolRef{ + {ID: "ctl:echo"}, + {ID: "ctl:echo", PinHash: "sha256/v1:bbbb"}, + }}, + wantMessage: "conflicting pin_hash", + }, + { + name: "wait_ms over the cap", + body: contracts.PreflightRequest{Tools: []contracts.PreflightToolRef{{ID: "ctl:echo"}}, WaitMS: preflightMaxWaitMS + 1}, + wantMessage: "exceeds the cap of 10000", + }, + { + name: "negative wait_ms", + body: contracts.PreflightRequest{Tools: []contracts.PreflightToolRef{{ID: "ctl:echo"}}, WaitMS: -1}, + wantMessage: "must not be negative", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctrl := &preflightController{last: preflightStep{outcome: readyOutcome("ctl:echo")}} + srv := newPreflightServer(t, ctrl) + + w := doPreflight(t, srv, tc.body) + + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, decodePreflightError(t, w), tc.wantMessage) + // A rejected request executed no preflight... + assert.Zero(t, ctrl.callCount(), "a 400 must not run the evaluator") + // ...and therefore wrote no activity record. + assert.Empty(t, ctrl.recordedActivity(), "a rejected request must not write an activity record") + }) + } +} + +// An unknown profile is a caller mistake, not proxy state: 400, and no record. +func TestPreflight_UnknownProfileIs400AndWritesNoRecord(t *testing.T) { + ctrl := &preflightController{last: preflightStep{ + err: fmt.Errorf("%w: %q", preflight.ErrUnknownProfile, "ghost"), + }} + srv := newPreflightServer(t, ctrl) + + w := doPreflight(t, srv, contracts.PreflightRequest{ + Tools: []contracts.PreflightToolRef{{ID: "ctl:echo"}}, + Profile: "ghost", + }) + + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, decodePreflightError(t, w), "ghost") + assert.Empty(t, ctrl.recordedActivity()) +} + +func TestPreflight_DedupPreservesFirstOccurrenceOrder(t *testing.T) { + ctrl := &preflightController{last: preflightStep{outcome: readyOutcome("ctl:echo", "ctl:add")}} + srv := newPreflightServer(t, ctrl) + + w := doPreflight(t, srv, contracts.PreflightRequest{Tools: []contracts.PreflightToolRef{ + {ID: "ctl:echo", PinHash: "sha256/v1:aaaa"}, + {ID: "ctl:add"}, + {ID: "ctl:echo", PinHash: "sha256/v1:aaaa"}, + }}) + + require.Equal(t, http.StatusOK, w.Code) + params := ctrl.capturedParams() + require.Len(t, params, 1) + require.Len(t, params[0].Tools, 2, "the duplicate id must be collapsed") + assert.Equal(t, "ctl:echo", params[0].Tools[0].ID) + assert.Equal(t, "sha256/v1:aaaa", params[0].Tools[0].PinHash) + assert.Equal(t, "ctl:add", params[0].Tools[1].ID) +} + +// --- T015: 503 rules -------------------------------------------------------- + +func TestPreflight_ServiceUnavailableRules(t *testing.T) { + tests := []struct { + name string + step preflightStep + recordErr error + wantMessage string + wantRecords int + }{ + { + name: "runtime unavailable", + step: preflightStep{err: preflight.ErrRuntimeUnavailable}, + wantMessage: "runtime is not ready", + }, + { + name: "evaluator infrastructure read failure", + step: preflightStep{err: fmt.Errorf("index read for server %q: %w", "ctl", errPreflightInfraRead)}, + wantMessage: "local state could not be read", + }, + { + name: "activity record could not be persisted", + step: preflightStep{outcome: readyOutcome("ctl:echo")}, + recordErr: internalRuntime.ErrActivityUnavailable, + wantMessage: "activity record could not be persisted", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctrl := &preflightController{last: tc.step, recordErr: tc.recordErr} + srv := newPreflightServer(t, ctrl) + + w := doPreflight(t, srv, contracts.PreflightRequest{Tools: []contracts.PreflightToolRef{{ID: "ctl:echo"}}}) + + require.Equal(t, http.StatusServiceUnavailable, w.Code) + body := decodePreflightError(t, w) + assert.Contains(t, body, "Preflight unavailable") + assert.Contains(t, body, tc.wantMessage) + assert.Len(t, ctrl.recordedActivity(), tc.wantRecords) + assert.NotContains(t, w.Body.String(), "\"verdict\"", "a 503 must not carry a reduced-fidelity verdict") + }) + } +} + +var errPreflightInfraRead = fmt.Errorf("bbolt: read failed") + +// --- T015: activity record (FR-014) ----------------------------------------- + +func TestPreflight_WritesActivityRecordBefore200(t *testing.T) { + blocked := unavailableOutcome("ctl:echo", preflight.ReasonToolChanged) + blocked.Results = append(blocked.Results, preflight.Result{ID: "ctl:add", Status: preflight.StatusReady}) + blocked.Verdict = preflight.VerdictForResults(blocked.Results) + + ctrl := &preflightController{last: preflightStep{outcome: blocked}} + srv := newPreflightServer(t, ctrl) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/preflight", + strings.NewReader(`{"tools":[{"id":"ctl:echo"},{"id":"ctl:add"}]}`)) + req.Header.Set("X-API-Key", preflightTestAPIKey) + req.Header.Set(XMCPProxyClientHeader, "cli/0.55.0") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + records := ctrl.recordedActivity() + require.Len(t, records, 1) + rec := records[0] + assert.Equal(t, preflight.VerdictBlocked, rec.Verdict) + assert.Equal(t, storage.ActivitySourceCLI, rec.Source, "the CLI client header must attribute the record to the CLI") + assert.Equal(t, w.Header().Get("X-Request-Id"), rec.RequestID, "the record must correlate with the response request id") + require.Len(t, rec.Tools, 2) + assert.Equal(t, preflight.ReasonToolChanged, rec.Tools[0].Reason) + assert.Empty(t, rec.Tools[1].Reason, "a ready tool contributes no reason code") +} + +// --- T015: tier detection --------------------------------------------------- + +func TestPreflightParams_TierDetection(t *testing.T) { + body := &contracts.PreflightRequest{ + Profile: "work", + Policy: &contracts.PreflightPolicy{ReadOnlyOnly: true, ExcludeOpenWorld: true}, + } + tools := []preflight.ToolRef{{ID: "ctl:echo"}} + + t.Run("api key is the operator tier", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/preflight", nil) + req = req.WithContext(auth.WithAuthContext(req.Context(), auth.AdminContext())) + + params := preflightParams(req, body, tools) + + assert.Equal(t, preflight.TierOperator, params.Tier) + assert.Nil(t, params.TokenServers) + assert.Empty(t, params.TokenProfilePin) + assert.Equal(t, "work", params.Profile) + assert.True(t, params.Filters.ReadOnlyOnly) + assert.True(t, params.Filters.ExcludeOpenWorld) + assert.False(t, params.Filters.ExcludeDestructive) + }) + + t.Run("socket connections are the operator tier", func(t *testing.T) { + // The socket/named-pipe path authenticates as admin, so it takes the + // same branch as an API key — asserted explicitly because FR-013 names + // the socket and pipe as operator surfaces. + req := httptest.NewRequest(http.MethodPost, "/api/v1/preflight", nil) + req = req.WithContext(auth.WithAuthContext(req.Context(), auth.AdminContext())) + assert.Equal(t, preflight.TierOperator, preflightParams(req, body, tools).Tier) + }) + + t.Run("no auth context defaults to operator", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/preflight", nil) + assert.Equal(t, preflight.TierOperator, preflightParams(req, body, tools).Tier) + }) + + t.Run("agent token is the scoped tier and carries its scope", func(t *testing.T) { + authCtx := (&auth.AgentToken{ + Name: "cron", + AllowedServers: []string{"ctl"}, + ProfilePin: "pinned", + }).AuthContext() + req := httptest.NewRequest(http.MethodPost, "/api/v1/preflight", nil) + req = req.WithContext(auth.WithAuthContext(req.Context(), authCtx)) + + params := preflightParams(req, body, tools) + + assert.Equal(t, preflight.TierAgentToken, params.Tier) + assert.Equal(t, []string{"ctl"}, params.TokenServers) + assert.Equal(t, "pinned", params.TokenProfilePin) + }) +} + +// End-to-end proof that a real agent token reaches the evaluator as the scoped +// tier: the middleware, not just the helper, must classify it. +func TestPreflight_AgentTokenRequestIsScopedTier(t *testing.T) { + tmpDir := t.TempDir() + _, err := auth.GetOrCreateHMACKey(tmpDir) + require.NoError(t, err) + rawToken, err := auth.GenerateToken() + require.NoError(t, err) + + ctrl := &preflightController{last: preflightStep{outcome: readyOutcome("ctl:echo")}} + srv := newPreflightServer(t, ctrl) + srv.SetTokenStore(&testTokenStore{ + validateFunc: func(token string, _ []byte) (*auth.AgentToken, error) { + if token != rawToken { + return nil, fmt.Errorf("token not found") + } + return &auth.AgentToken{ + Name: "cron", + TokenPrefix: auth.TokenPrefix(rawToken), + AllowedServers: []string{"ctl"}, + Permissions: []string{auth.PermRead}, + ProfilePin: "pinned", + ExpiresAt: time.Now().Add(time.Hour), + }, nil + }, + }, tmpDir) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/preflight", + strings.NewReader(`{"tools":[{"id":"ctl:echo"}]}`)) + req.Header.Set("X-API-Key", rawToken) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + params := ctrl.capturedParams() + require.Len(t, params, 1) + assert.Equal(t, preflight.TierAgentToken, params[0].Tier) + assert.Equal(t, []string{"ctl"}, params[0].TokenServers) + assert.Equal(t, "pinned", params[0].TokenProfilePin) +} + +// --- T016: wait_ms ---------------------------------------------------------- + +func TestPreflightWait_ResolvesAtDeadlineWithCurrentReasons(t *testing.T) { + // State never improves: the loop must stop at the deadline and answer with + // the current (retryable) reason rather than hanging. + ctrl := &preflightController{last: preflightStep{outcome: unavailableOutcome("ctl:echo", preflight.ReasonServerInitializing)}} + srv := newPreflightServer(t, ctrl) + srv.preflightPollOverride = 5 * time.Millisecond + + start := time.Now() + w := doPreflight(t, srv, contracts.PreflightRequest{ + Tools: []contracts.PreflightToolRef{{ID: "ctl:echo"}}, + WaitMS: 60, + }) + elapsed := time.Since(start) + + require.Equal(t, http.StatusOK, w.Code) + resp := decodePreflightResponse(t, w) + assert.Equal(t, preflight.VerdictDegradedRetryable, resp.Verdict) + require.NotNil(t, resp.WaitedMS) + assert.GreaterOrEqual(t, *resp.WaitedMS, 40, "it should have waited close to the whole budget") + assert.Less(t, elapsed, 5*time.Second, "the wait must be bounded by the requested budget") + assert.Greater(t, ctrl.callCount(), 2, "it must re-evaluate local state while waiting") +} + +func TestPreflightWait_StopsAsSoonAsEverythingIsReady(t *testing.T) { + ctrl := &preflightController{script: []preflightStep{ + {outcome: unavailableOutcome("ctl:echo", preflight.ReasonServerInitializing)}, + {outcome: readyOutcome("ctl:echo")}, + }} + srv := newPreflightServer(t, ctrl) + srv.preflightPollOverride = 5 * time.Millisecond + + w := doPreflight(t, srv, contracts.PreflightRequest{ + Tools: []contracts.PreflightToolRef{{ID: "ctl:echo"}}, + WaitMS: 5000, + }) + + require.Equal(t, http.StatusOK, w.Code) + resp := decodePreflightResponse(t, w) + assert.Equal(t, preflight.VerdictReady, resp.Verdict) + assert.Equal(t, 2, ctrl.callCount()) + require.NotNil(t, resp.WaitedMS) + assert.Less(t, *resp.WaitedMS, 5000) +} + +// Waiting cannot clear a non-retryable failure, so the loop must terminate the +// moment one appears (FR-012) instead of burning the whole budget. +func TestPreflightWait_TerminatesEarlyOnNonRetryableFailure(t *testing.T) { + ctrl := &preflightController{script: []preflightStep{ + {outcome: unavailableOutcome("ctl:echo", preflight.ReasonServerInitializing)}, + {outcome: unavailableOutcome("ctl:echo", preflight.ReasonServerQuarantined)}, + }} + srv := newPreflightServer(t, ctrl) + srv.preflightPollOverride = 5 * time.Millisecond + + start := time.Now() + w := doPreflight(t, srv, contracts.PreflightRequest{ + Tools: []contracts.PreflightToolRef{{ID: "ctl:echo"}}, + WaitMS: 10000, + }) + elapsed := time.Since(start) + + require.Equal(t, http.StatusOK, w.Code) + resp := decodePreflightResponse(t, w) + assert.Equal(t, preflight.VerdictBlocked, resp.Verdict) + assert.Equal(t, 2, ctrl.callCount()) + assert.Less(t, elapsed, 2*time.Second, "it must not wait out the 10s budget for a blocked tool") +} + +// A blocked-on-arrival set never sleeps at all, and never takes a wait slot. +func TestPreflightWait_NotEnteredWhenNothingIsRetryable(t *testing.T) { + ctrl := &preflightController{last: preflightStep{outcome: unavailableOutcome("ctl:echo", preflight.ReasonNotFound)}} + srv := newPreflightServer(t, ctrl) + + w := doPreflight(t, srv, contracts.PreflightRequest{ + Tools: []contracts.PreflightToolRef{{ID: "ctl:echo"}}, + WaitMS: 10000, + }) + + require.Equal(t, http.StatusOK, w.Code) + resp := decodePreflightResponse(t, w) + assert.Equal(t, preflight.VerdictUnknownIDs, resp.Verdict) + assert.Equal(t, 1, ctrl.callCount()) + require.NotNil(t, resp.WaitedMS) + assert.Equal(t, 0, *resp.WaitedMS) +} + +// With the dedicated wait budget exhausted the request degrades gracefully: it +// resolves immediately with waited_ms 0 instead of queueing or failing. +func TestPreflightWait_SemaphoreExhaustedDegradesToImmediateResolve(t *testing.T) { + ctrl := &preflightController{last: preflightStep{outcome: unavailableOutcome("ctl:echo", preflight.ReasonServerInitializing)}} + srv := newPreflightServer(t, ctrl) + srv.preflightPollOverride = 5 * time.Millisecond + + for i := 0; i < preflightWaitSlots; i++ { + require.True(t, srv.acquirePreflightWaitSlot()) + } + require.False(t, srv.acquirePreflightWaitSlot(), "the budget is fixed and small") + + start := time.Now() + w := doPreflight(t, srv, contracts.PreflightRequest{ + Tools: []contracts.PreflightToolRef{{ID: "ctl:echo"}}, + WaitMS: 10000, + }) + elapsed := time.Since(start) + + require.Equal(t, http.StatusOK, w.Code) + resp := decodePreflightResponse(t, w) + assert.Equal(t, preflight.VerdictDegradedRetryable, resp.Verdict) + require.NotNil(t, resp.WaitedMS) + assert.Equal(t, 0, *resp.WaitedMS) + assert.Equal(t, 1, ctrl.callCount(), "an exhausted budget must not poll") + assert.Less(t, elapsed, 2*time.Second) +} + +// The slot is returned when the wait finishes, or a handful of waiting cron +// jobs would permanently disable waiting for everyone. +func TestPreflightWait_SlotIsReleasedAfterWaiting(t *testing.T) { + ctrl := &preflightController{last: preflightStep{outcome: unavailableOutcome("ctl:echo", preflight.ReasonServerInitializing)}} + srv := newPreflightServer(t, ctrl) + srv.preflightPollOverride = 5 * time.Millisecond + + for i := 0; i < 2; i++ { + w := doPreflight(t, srv, contracts.PreflightRequest{ + Tools: []contracts.PreflightToolRef{{ID: "ctl:echo"}}, + WaitMS: 20, + }) + require.Equal(t, http.StatusOK, w.Code) + } + + for i := 0; i < preflightWaitSlots; i++ { + assert.True(t, srv.acquirePreflightWaitSlot(), "every slot must be free again") + } +} + +func TestPreflightPollInterval_DefaultsToTheSpecFloor(t *testing.T) { + srv := newPreflightServer(t, &preflightController{}) + assert.Equal(t, 250*time.Millisecond, srv.preflightPollInterval()) + assert.Equal(t, 250*time.Millisecond, preflightPollFloor, "FR-012 floors polling at 250ms") +} + +// --- unit-level validation helpers ------------------------------------------ + +func TestNormalizePreflightTools(t *testing.T) { + t.Run("trims ids and pins", func(t *testing.T) { + out, err := normalizePreflightTools([]contracts.PreflightToolRef{{ID: " ctl:echo ", PinHash: " sha256/v1:aa "}}) + require.NoError(t, err) + require.Len(t, out, 1) + assert.Equal(t, "ctl:echo", out[0].ID) + assert.Equal(t, "sha256/v1:aa", out[0].PinHash) + }) + + t.Run("a malformed id is not a request error", func(t *testing.T) { + // One bad entry must not mask the verdicts of the rest — it becomes a + // per-ID not_found in the evaluator, so validation lets it through. + out, err := normalizePreflightTools([]contracts.PreflightToolRef{{ID: "no-separator"}, {ID: "ctl:echo"}}) + require.NoError(t, err) + assert.Len(t, out, 2) + }) + + t.Run("exactly the limit is accepted", func(t *testing.T) { + refs := make([]contracts.PreflightToolRef, 0, preflightMaxTools) + for i := 0; i < preflightMaxTools; i++ { + refs = append(refs, contracts.PreflightToolRef{ID: fmt.Sprintf("ctl:tool%d", i)}) + } + out, err := normalizePreflightTools(refs) + require.NoError(t, err) + assert.Len(t, out, preflightMaxTools) + }) +} diff --git a/internal/httpapi/security_test.go b/internal/httpapi/security_test.go index d934e157..8bebd7cf 100644 --- a/internal/httpapi/security_test.go +++ b/internal/httpapi/security_test.go @@ -11,6 +11,7 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" "github.com/smart-mcp-proxy/mcpproxy-go/internal/secret" "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" @@ -364,6 +365,10 @@ func (m *baseController) GetToolApproval(_, _ string) (*storage.ToolApprovalReco return nil, nil } func (m *baseController) GetToolApprovalStatus(_, _ string) (string, error) { return "", nil } +func (m *baseController) RunPreflight(_ context.Context, _ preflight.Params) (preflight.Outcome, error) { + return preflight.Outcome{}, nil +} +func (m *baseController) RecordPreflight(_ runtime.PreflightActivity) error { return nil } func (m *baseController) GetOnboardingState() (*storage.OnboardingState, error) { return &storage.OnboardingState{}, nil } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 6d14d70c..3f99a9a4 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -29,6 +29,7 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/management" "github.com/smart-mcp-proxy/mcpproxy-go/internal/oauth" "github.com/smart-mcp-proxy/mcpproxy-go/internal/observability" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/preflight" "github.com/smart-mcp-proxy/mcpproxy-go/internal/registries" "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext" internalRuntime "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" @@ -114,6 +115,15 @@ type ServerController interface { UpdateServer(ctx context.Context, serverName string, updates *config.ServerConfig) error EnableServer(serverName string, enabled bool) error GetToolApprovalStatus(serverName, toolName string) (string, error) + // RunPreflight evaluates a required-tools preflight against local state + // only (Spec 098). The core owns the index/storage/stateview wiring; this + // interface is the only way the REST layer reaches it — same precedent as + // GetToolApprovalStatus above. + RunPreflight(ctx context.Context, params preflight.Params) (preflight.Outcome, error) + // RecordPreflight persists one preflight run synchronously and returns the + // write error, so the handler can answer 503 when the durable record + // required by FR-014 could not be written. + RecordPreflight(rec internalRuntime.PreflightActivity) error RestartServer(serverName string) error ForceReconnectAllServers(reason string) error GetDockerRecoveryStatus() *storage.DockerRecoveryState @@ -296,6 +306,16 @@ type Server struct { // override a live MCP session's set_profile selection. activeProfileMu sync.RWMutex activeProfile string + + // preflightWaitSem is the dedicated wait budget for POST /api/v1/preflight + // (Spec 098 FR-012): a buffered channel used as a non-blocking semaphore, so + // a flood of waiting preflights degrades to immediate answers instead of + // queueing. nil (a Server not built by NewServer) reads as "exhausted", + // which is the safe direction. + preflightWaitSem chan struct{} + // preflightPollOverride lowers the 250 ms poll floor. Tests set it; nothing + // in production does. + preflightPollOverride time.Duration } // usageCacheEntry is one cached usage response with its expiry. @@ -359,11 +379,12 @@ func NewServer(controller ServerController, logger *zap.SugaredLogger, obs *obse } s := &Server{ - controller: controller, - logger: logger, - httpLogger: httpLogger, - router: chi.NewRouter(), - observability: obs, + controller: controller, + logger: logger, + httpLogger: httpLogger, + router: chi.NewRouter(), + observability: obs, + preflightWaitSem: make(chan struct{}, preflightWaitSlots), } s.setupRoutes() @@ -511,13 +532,12 @@ func (s *Server) handleAgentTokenAuth(w http.ResponseWriter, r *http.Request, ne } }() - authCtx := &auth.AuthContext{ - Type: auth.AuthTypeAgent, - AgentName: agentToken.Name, - TokenPrefix: agentToken.TokenPrefix, - AllowedServers: agentToken.AllowedServers, - Permissions: agentToken.Permissions, - } + // Built through the shared constructor so every field the MCP path carries + // reaches the REST path too — notably ProfilePin, which this path dropped + // before Spec 098. Evaluation scope is token scope ∩ token pin ∩ requested + // profile (preflight.ResolveScope), so a dropped pin silently widened what + // a pinned token could see. + authCtx := agentToken.AuthContext() ctx := auth.WithAuthContext(r.Context(), authCtx) s.logger.Debug("Agent token authenticated", @@ -839,6 +859,12 @@ func (s *Server) setupRoutes() { // Tool execution r.Post("/tools/call", s.handleCallTool) + // Required-tools preflight (Spec 098). POST because the check takes a + // body, but it is strictly read-only — zero upstream I/O, zero runtime + // mutation — so it carries no requireServerOp gate and agent tokens may + // call it (they get the scope-silenced disclosure tier). + r.Post("/preflight", s.handlePreflight) + // Code execution endpoint (for CLI client mode) r.Post("/code/exec", NewCodeExecHandler(s.controller, s.logger).ServeHTTP) @@ -3014,7 +3040,9 @@ func (s *Server) handleGetServerTools(w http.ResponseWriter, r *http.Request) { } // Convert + enrich (shared with the global tools endpoint, spec 050). - typedTools := s.enrichServerTools(serverID, tools) + // Hash pins are operator-tier only (Spec 098 T020). + tier, _ := disclosureTier(r) + typedTools := s.enrichServerTools(serverID, tools, tier == preflight.TierOperator) // Sort: pending/changed tools first, then approved sort.SliceStable(typedTools, func(i, j int) bool { @@ -3035,7 +3063,12 @@ func (s *Server) handleGetServerTools(w http.ResponseWriter, r *http.Request) { // Shared by the per-server tools endpoint and the global tools endpoint // (spec 050). ServerName is forced to serverID so the global merge can attribute // every tool to its server even if the upstream payload omits server_name. -func (s *Server) enrichServerTools(serverID string, tools []map[string]interface{}) []contracts.Tool { +// +// discloseHash (Spec 098 T020) additionally publishes the approval record's +// current hash as a preflight pin. Callers derive it from the request's +// disclosure tier — never pass true unconditionally: the agent-token tier must +// not receive hashes (FR-013). +func (s *Server) enrichServerTools(serverID string, tools []map[string]interface{}, discloseHash bool) []contracts.Tool { typedTools := contracts.ConvertGenericToolsToTyped(tools) type configDeniedChecker interface { @@ -3056,6 +3089,13 @@ func (s *Server) enrichServerTools(serverID string, tools []map[string]interface typedTools[i].HeldReason = record.HeldReason typedTools[i].HeldVerdict = record.HeldVerdict typedTools[i].HeldSignals = record.HeldSignals + // Hash-pin authoring surface (Spec 098 FR-011). Same guard as the + // preflight evaluator: operator tier only, and only when a hash is + // actually stored — otherwise the field stays absent instead of + // rendering a "sha256/v0:" placeholder. + if discloseHash && record.CurrentHash != "" { + typedTools[i].Hash = preflight.FormatPin(record.HashSchemaVersion, record.CurrentHash) + } enrichedCount++ } else if i == 0 { firstErr = err @@ -3119,6 +3159,10 @@ func (s *Server) handleGetGlobalTools(w http.ResponseWriter, r *http.Request) { Tools: make([]contracts.Tool, 0, 256), } + // Hash pins are operator-tier only (Spec 098 T020). + tier, _ := disclosureTier(r) + discloseHash := tier == preflight.TierOperator + for _, srv := range allServers { name, _ := srv["name"].(string) if name == "" { @@ -3135,7 +3179,7 @@ func (s *Server) handleGetGlobalTools(w http.ResponseWriter, r *http.Request) { continue } - typed := s.enrichServerTools(name, generic) + typed := s.enrichServerTools(name, generic, discloseHash) for i := range typed { if st, ok := usage[name+"\x00"+typed[i].Name]; ok { typed[i].Usage = st.Count diff --git a/internal/httpapi/tool_hash_disclosure_test.go b/internal/httpapi/tool_hash_disclosure_test.go new file mode 100644 index 00000000..8375cb15 --- /dev/null +++ b/internal/httpapi/tool_hash_disclosure_test.go @@ -0,0 +1,197 @@ +package httpapi + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" +) + +// T020 — hash-pin authoring surface (Spec 098 FR-011). +// +// The per-tool REST payload carries the tool's current hash rendered as the +// preflight pin format "sha256/v{N}:{hex}" so an operator can author +// `--pin id=` without a second endpoint. Disclosure is operator-tier +// only: the agent-token tier must never see a hash, on any tool endpoint — +// otherwise an agent can fingerprint tools it is not scoped to and detect +// upstream drift the operator has not published to it. + +const toolHashTestAPIKey = "tool-hash-test-api-key" + +// toolHashMgmtService is the management service the per-server tools endpoint +// prefers; it serves a fixed tool set for one server. +type toolHashMgmtService struct { + tools map[string][]map[string]interface{} +} + +func (m *toolHashMgmtService) GetServerTools(_ context.Context, name string) ([]map[string]interface{}, error) { + return m.tools[name], nil +} + +// toolHashController drives both tool listing endpoints with a real API key +// configured so the auth middleware runs (agent tokens need it). +type toolHashController struct { + globalToolsController + mgmt *toolHashMgmtService +} + +func (c *toolHashController) GetCurrentConfig() interface{} { + return &config.Config{APIKey: toolHashTestAPIKey} +} + +func (c *toolHashController) GetManagementService() interface{} { + if c.mgmt == nil { + return nil + } + return c.mgmt +} + +func newToolHashController() *toolHashController { + tools := map[string][]map[string]interface{}{ + "github": { + {"name": "create_issue", "description": "Create issue"}, + {"name": "no_record", "description": "Never approved"}, + }, + } + ctrl := &toolHashController{ + globalToolsController: globalToolsController{ + allServers: []map[string]interface{}{{"name": "github"}}, + serverTools: tools, + approvals: map[string]*storage.ToolApprovalRecord{ + "github\x00create_issue": { + Status: storage.ToolApprovalStatusApproved, + CurrentHash: "abc123", + HashSchemaVersion: 3, + }, + }, + }, + mgmt: &toolHashMgmtService{tools: tools}, + } + return ctrl +} + +// newToolHashAgentToken registers a valid agent token on srv and returns it. +func newToolHashAgentToken(t *testing.T, srv *Server) string { + t.Helper() + tmpDir := t.TempDir() + _, err := auth.GetOrCreateHMACKey(tmpDir) + require.NoError(t, err) + rawToken, err := auth.GenerateToken() + require.NoError(t, err) + srv.SetTokenStore(&testTokenStore{ + validateFunc: func(token string, _ []byte) (*auth.AgentToken, error) { + if token != rawToken { + return nil, fmt.Errorf("token not found") + } + return &auth.AgentToken{ + Name: "cron", + TokenPrefix: auth.TokenPrefix(rawToken), + AllowedServers: []string{"*"}, + Permissions: []string{auth.PermRead}, + ExpiresAt: time.Now().Add(time.Hour), + }, nil + }, + }, tmpDir) + return rawToken +} + +// fetchTools calls a tool-listing endpoint with the given credential and +// returns the tools keyed by name. +func fetchTools(t *testing.T, srv *Server, path, token string) map[string]map[string]interface{} { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("X-API-Key", token) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + + var resp struct { + Data struct { + Tools []map[string]interface{} `json:"tools"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + byName := map[string]map[string]interface{}{} + for _, tool := range resp.Data.Tools { + name, _ := tool["name"].(string) + byName[name] = tool + } + return byName +} + +func TestToolHash_OperatorTierSeesPin(t *testing.T) { + for _, path := range []string{"/api/v1/tools", "/api/v1/servers/github/tools"} { + t.Run(path, func(t *testing.T) { + srv := NewServer(newToolHashController(), zaptest.NewLogger(t).Sugar(), nil) + tools := fetchTools(t, srv, path, toolHashTestAPIKey) + + require.Contains(t, tools, "create_issue") + assert.Equal(t, "sha256/v3:abc123", tools["create_issue"]["hash"], + "operator tier gets the current hash in pin format") + + // A tool with no approval record has no hash to publish; the field + // must be absent rather than an empty/garbage pin. + require.Contains(t, tools, "no_record") + assert.NotContains(t, tools["no_record"], "hash") + }) + } +} + +func TestToolHash_NeverDisclosedToAgentToken(t *testing.T) { + for _, path := range []string{"/api/v1/tools", "/api/v1/servers/github/tools"} { + t.Run(path, func(t *testing.T) { + srv := NewServer(newToolHashController(), zaptest.NewLogger(t).Sugar(), nil) + token := newToolHashAgentToken(t, srv) + + tools := fetchTools(t, srv, path, token) + + require.Contains(t, tools, "create_issue", "the agent still sees the tool itself") + assert.NotContains(t, tools["create_issue"], "hash", + "agent-token tier must never receive a hash pin") + // The rest of the enrichment is unchanged for agents. + assert.Equal(t, storage.ToolApprovalStatusApproved, tools["create_issue"]["approval_status"]) + }) + } +} + +// The hash is proxy state, never upstream-supplied: a server declaring a tool +// field literally named "hash" must not be able to publish a pin through the +// listing (it would let a malicious upstream pin itself to a value the operator +// never approved, and would leak a pin to the agent tier). +func TestToolHash_UpstreamSuppliedHashIsNotReflected(t *testing.T) { + ctrl := newToolHashController() + ctrl.serverTools["github"] = []map[string]interface{}{ + {"name": "no_record", "description": "Never approved", "hash": "sha256/v3:spoofed"}, + } + ctrl.mgmt.tools = ctrl.serverTools + srv := NewServer(ctrl, zaptest.NewLogger(t).Sugar(), nil) + + tools := fetchTools(t, srv, "/api/v1/tools", toolHashTestAPIKey) + require.Contains(t, tools, "no_record") + assert.NotContains(t, tools["no_record"], "hash") +} + +// A record whose hash is empty (pre-hash record) must not render a "sha256/v0:" +// placeholder pin. +func TestToolHash_EmptyStoredHashIsOmitted(t *testing.T) { + ctrl := newToolHashController() + ctrl.approvals["github\x00create_issue"] = &storage.ToolApprovalRecord{ + Status: storage.ToolApprovalStatusApproved, + } + srv := NewServer(ctrl, zaptest.NewLogger(t).Sugar(), nil) + + tools := fetchTools(t, srv, "/api/v1/tools", toolHashTestAPIKey) + require.Contains(t, tools, "create_issue") + assert.NotContains(t, tools["create_issue"], "hash") +} diff --git a/internal/serveredition/api/user_activity_test.go b/internal/serveredition/api/user_activity_test.go index d89e6175..4d469004 100644 --- a/internal/serveredition/api/user_activity_test.go +++ b/internal/serveredition/api/user_activity_test.go @@ -30,9 +30,7 @@ type mockActivityProvider struct { func (m *mockActivityProvider) ListActivities(filter storage.ActivityFilter) ([]*storage.ActivityRecord, int, error) { var matched []*storage.ActivityRecord - for _, r := range m.records { - matched = append(matched, r) - } + matched = append(matched, m.records...) total := len(matched) // Apply pagination. diff --git a/internal/serveredition/auth/jwt_tokens_test.go b/internal/serveredition/auth/jwt_tokens_test.go index 8a2d07ca..9626de88 100644 --- a/internal/serveredition/auth/jwt_tokens_test.go +++ b/internal/serveredition/auth/jwt_tokens_test.go @@ -65,7 +65,7 @@ func TestGenerateBearerToken_Claims(t *testing.T) { } // Expiry should be approximately 2 hours from now expectedExpiry := time.Now().UTC().Add(2 * time.Hour) - diff := claims.ExpiresAt.Time.Sub(expectedExpiry) + diff := claims.ExpiresAt.Sub(expectedExpiry) if diff < -5*time.Second || diff > 5*time.Second { t.Errorf("expiry differs from expected by %v", diff) } diff --git a/oas/docs.go b/oas/docs.go index 445c93dd..c6f3090d 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -6,10 +6,10 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"config.ConcurrencyDefaults":{"description":"ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server\ndefault set inherited by every server that does not override a setting.\nAbsent (the default) = no per-server limiting unless a server configures\nit explicitly. File/API-configured only — no env scheme (FR-022).","properties":{"max_concurrent_requests":{"type":"integer"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"}},"type":"object"},"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_max_size_mb":{"description":"Max total activity-log size in MB before pruning oldest (default: 256, 0=disabled)","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"allow_private_registry_fetch":{"description":"AllowPrivateRegistryFetch opts out of the registry SSRF guard (MCP-1076,\nCWE-918). By default (false) registry fetches refuse any host that is — or\nresolves to — a non-routable address (loopback, RFC1918/CGNAT private,\nlink-local incl. the 169.254.169.254 cloud-metadata endpoint), so a\nmalicious or typo'd registry source cannot turn the daemon into a\nrequest-forgery vector against internal services.\n\nThis opt-out is BLANKET (all-or-nothing): setting it true disables the\nguard for EVERY non-routable range at once — loopback, RFC1918/CGNAT\nprivate, link-local AND the 169.254.169.254 cloud-metadata endpoint. There\nis no way to allow only loopback; enabling it for a localhost dev registry\nalso re-opens the cloud-metadata SSRF vector. Set true ONLY when you\nintentionally run a trusted registry mirror on an internal/private address,\nideally on a host with no cloud-metadata exposure. The change takes effect\nonly on daemon (re)start or config reload.","type":"boolean"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"api_key":{"description":"Security settings","type":"string"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_parallel":{"description":"Default concurrency for call_tools() batches (1-32, default: 8)","type":"integer"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"disable_management":{"type":"boolean"},"docker_isolation":{"$ref":"#/components/schemas/config.DockerIsolationConfig"},"docker_recovery":{"$ref":"#/components/schemas/config.DockerRecoveryConfig"},"enable_code_execution":{"description":"Code execution settings","type":"boolean"},"enable_prompts":{"description":"Prompts settings","type":"boolean"},"enable_socket":{"description":"Enable Unix socket/named pipe for local IPC (default: true)","type":"boolean"},"enable_tray":{"description":"Deprecated: EnableTray is unused and has no runtime effect. Kept for backward compatibility.","type":"boolean"},"environment":{"$ref":"#/components/schemas/secureenv.EnvConfig"},"features":{"$ref":"#/components/schemas/config.FeatureFlags"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"http_idle_timeout":{"description":"HTTPIdleTimeout caps how long an idle keep-alive connection is kept open.\nUnset = 180s. \"0s\" removes the dedicated idle deadline, but net/http then\nfalls back to ReadTimeout — idle is fully unbounded only when\nhttp_read_timeout is also \"0s\". Requires a restart.","type":"string"},"http_read_timeout":{"description":"HTTPReadTimeout caps how long reading a whole request (headers + body)\nmay take. Unset = 120s; \"0s\" disables it. Requires a restart.","type":"string"},"http_write_timeout":{"description":"HTTPWriteTimeout caps how long producing a whole response may take on\nnon-streaming endpoints (REST, Web UI, health). Unset = 120s; \"0s\"\ndisables it globally. MCP and SSE /events routes are exempt by design.","type":"string"},"init_timeout":{"description":"InitTimeout is the global default deadline for an upstream's MCP\n` + "`" + `initialize` + "`" + ` handshake (MCP-3322 / GH #760). *Duration tri-state: nil =\ninherit the built-in 30s default; a positive value = that deadline. A\nper-server InitTimeout overrides this. Resolved by ResolveInitTimeout;\nvalidated to {0} ∪ [1s, 30m] in Validate(). Servers doing legitimate\nfirst-run warmup (cache/index build) before answering ` + "`" + `initialize` + "`" + ` can\nraise this so they are not killed mid-startup.","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_concurrent_requests":{"description":"Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL\nAGGREGATE limiter — one proxy-wide cap on concurrently running upstream\ntool calls, with its own bounded wait queue. Tri-state pointers: absent =\nthe limiter does not exist (default, zero behavior change); an explicit 0\nmax also disables it; positive = that cap. This scope is NEVER a\nper-server inheritance source — per-server values come from\nServerConcurrencyDefaults / the per-server overrides — but a server's\neffective concurrency is bounded by BOTH its own limiter and this one.\nResolved by ResolveGlobalConcurrency; hot-reloadable; overridable via\nMCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).","type":"integer"},"max_result_size_chars":{"description":"Advertised on every tool as ` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; raises Claude Code's inline-response ceiling from 50k to up to 500k chars. Set to 0 to disable.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of the\nsecret-bearing server fields — sensitive header values (Authorization,\nX-API-Key, Cookie, …), env-var secrets, and URL query credentials — in\nresponses from the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + `\nREST API, and the SSE event stream. It also lets URL secrets echoed\ninto last_error / health.detail through unscrubbed.\n\nDefault false — sensitive values are surfaced masked as\n` + "`" + `••••\u003clast2\u003e (\u003cN\u003e chars)` + "`" + ` (error strings use ` + "`" + `***REDACTED***` + "`" + `) so an\nMCP agent cannot read Bearer tokens / API keys / URL secrets out of\nanother upstream's config (PR #425, issue #872). ${env:…}/${keyring:…}\nreferences are labels, not secrets, and pass through unchanged.\n\nThe Web UI / macOS tray edit forms work without seeing the real\nvalues: PATCH /api/v1/servers/{id} deep-merges (omitted keys are\npreserved, see ` + "`" + `headers_remove` + "`" + ` / ` + "`" + `env_remove` + "`" + ` for explicit\ndeletes), so clients compute a diff and only send the keys that\nactually changed. Redacted-but-unchanged values never round-trip\n— the backend keeps the real string. Set this to true if a\ndownstream tool genuinely needs raw values in the response.","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"security":{"$ref":"#/components/schemas/config.SecurityConfig"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"server_concurrency_defaults":{"$ref":"#/components/schemas/config.ConcurrencyDefaults"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_discovery_interval":{"type":"string"},"tool_response_limit":{"type":"integer"},"tool_response_mode":{"description":"Tool response mode (Spec 085): how retrieve_tools serializes results.\nValid values: \"\" (= full), \"full\" (default: today's schema-bearing\nentries), \"compact\" (signature + first-sentence entries). Orthogonal to\nrouting_mode — routing_mode selects the tool SURFACE, this selects the\nSERIALIZATION within the retrieve_tools surface. Serialization-only: it\nnever affects the query, ranking, or result set. Hot-reloadable.","type":"string"},"tool_response_session_risk_warning":{"description":"ToolResponseSessionRiskWarning controls whether the prose ` + "`" + `warning` + "`" + ` field\nis included in the ` + "`" + `session_risk` + "`" + ` object returned by ` + "`" + `retrieve_tools` + "`" + `.\nThe structured fields (level, lethal_trifecta, has_open_world_tools, etc.)\nare always included. Default: false (quiet for LLM clients) — see issue #406.\nMost tools lack annotations, so the MCP-spec defaults treat them as fully\npermissive across all three risk axes, which makes the prose warning fire\non almost every call and wastes tokens.","type":"boolean"},"tools_limit":{"type":"integer"},"toon_min_savings_pct":{"description":"ToonMinSavingsPct is the minimum byte-savings percentage (validated\n1-90; 0/unset → 15) the complete TOON emission (marker + hint + body)\nmust achieve over the exact passthrough emission for adaptive mode to\nencode a block. Byte savings approximate token savings for the tabular\npayload class; the spec-083 profiler reports true token deltas.\nGlobal-only (no per-server override, FR-001).","type":"integer"},"toon_output":{"description":"ToonOutput selects the TOON encoding mode for call_tool_* result text\nblocks (spec 084): \"off\" (default — responses byte-identical to\npre-feature behavior), \"adaptive\" (encode only tabular-uniform payloads\nthat beat compact JSON by ToonMinSavingsPct), or \"always\"\n(benchmark/debug only — encodes every JSON-parseable block and can\nINCREASE token cost). Per-server override: ServerConfig.ToonOutput.\nResolved by ResolveToonOutput; hot-reloadable.","type":"string"},"top_k":{"description":"Deprecated: TopK is superseded by ToolsLimit and has no runtime effect. Kept for backward compatibility.","type":"integer"},"tray_endpoint":{"description":"Tray endpoint override (unix:// or npipe://)","type":"string"},"trusted_hosts":{"description":"TrustedHosts lists non-loopback Host header values accepted on loopback\nlisteners (GH #898). DNS-rebinding protection rejects requests whose Host\nheader is not a loopback address when mcpproxy listens on loopback; a\nreverse proxy (nginx → 127.0.0.1) forwarding the public domain in Host\ntrips it. Entries are hostnames, case-insensitive; an entry without a\nport matches any port, with a port it must match exactly; a leading dot\n(\".example.com\") is a subdomain wildcard. The single entry \"*\" disables\nHost and Origin validation entirely. The same list also validates the\nOrigin header when present (MCP spec DNS-rebinding defense). Empty\n(default) keeps full protection. Env override: MCPPROXY_TRUSTED_HOSTS\n(comma-separated).","items":{"type":"string"},"type":"array","uniqueItems":false},"update_check":{"$ref":"#/components/schemas/config.UpdateCheckConfig"}},"type":"object"},"config.CustomPattern":{"properties":{"category":{"description":"Category (defaults to \"custom\")","type":"string"},"keywords":{"description":"Keywords to match (mutually exclusive with Regex)","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Unique identifier for this pattern","type":"string"},"regex":{"description":"Regex pattern (mutually exclusive with Keywords)","type":"string"},"severity":{"description":"Risk level: critical, high, medium, low","type":"string"}},"type":"object"},"config.DeepScanConfig":{"description":"DeepScan is the opt-in \"deep scan\" layer (Spec 077 US3). It subsumes the\ndeprecated top-level scanner_fetch_package_source / scanner_disable_no_new_privileges\nkeys (migrated on load) and gates the heavy Docker-based scanners + source\nextraction. Disabled by default (FR-006): only the deterministic in-process\nbaseline scanner runs. A deep-scan failure NEVER changes the baseline verdict\n(FR-007/FR-008).","properties":{"disable_no_new_privileges":{"description":"DisableNoNewPrivileges, when true, omits the ` + "`" + `--security-opt\nno-new-privileges` + "`" + ` flag from scanner container runs (snap-docker/AppArmor\nescape hatch). Absorbs the deprecated top-level\nscanner_disable_no_new_privileges. Default false.","type":"boolean"},"enabled":{"description":"Enabled is the master opt-in for the heavy layer (FR-006). Default false.","type":"boolean"},"fetch_package_source":{"description":"FetchPackageSource controls whether the scanner fetches the PUBLISHED\nsource of package-runner servers (npx/uvx) — without executing it — when\nno local source is available. Absorbs the deprecated top-level\nscanner_fetch_package_source. Default (nil) is ENABLED within deep scan.","type":"boolean"},"scanners":{"description":"Scanners optionally restricts which deep scanners may run under the\numbrella (by scanner id). Empty ⇒ all enabled deep scanners are eligible.","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.DockerIsolationConfig":{"description":"Docker isolation settings","properties":{"cpu_limit":{"description":"CPU limit for containers","type":"string"},"default_images":{"additionalProperties":{"type":"string"},"description":"Map of runtime type to Docker image","type":"object"},"enable_cache_volume":{"description":"Mount shared cache volumes for faster restarts (default: true)","type":"boolean"},"enabled":{"description":"Global enable/disable for Docker isolation (legacy; superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments","items":{"type":"string"},"type":"array","uniqueItems":false},"log_driver":{"description":"Docker log driver (default: json-file)","type":"string"},"log_max_files":{"description":"Maximum number of log files (default: 3)","type":"string"},"log_max_size":{"description":"Maximum size of log files (default: 100m)","type":"string"},"memory_limit":{"description":"Memory limit for containers","type":"string"},"mode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"network_mode":{"description":"Docker network mode (default: bridge)","type":"string"},"registry":{"description":"Custom registry (defaults to docker.io)","type":"string"},"timeout":{"description":"Container startup timeout","type":"string"}},"type":"object"},"config.DockerRecoveryConfig":{"description":"Docker recovery settings","properties":{"enabled":{"description":"Enable Docker recovery monitoring (default: true)","type":"boolean"},"max_retries":{"description":"Maximum retry attempts (0 = unlimited)","type":"integer"},"notify_on_failure":{"description":"Show notification on recovery failure (default: true)","type":"boolean"},"notify_on_retry":{"description":"Show notification on each retry (default: false)","type":"boolean"},"notify_on_start":{"description":"Show notification when recovery starts (default: true)","type":"boolean"},"notify_on_success":{"description":"Show notification on successful recovery (default: true)","type":"boolean"},"persistent_state":{"description":"Save recovery state across restarts (default: true)","type":"boolean"}},"type":"object"},"config.FeatureFlags":{"description":"Deprecated: Features flags are unused and have no runtime effect. Kept for backward compatibility.","properties":{"enable_async_storage":{"type":"boolean"},"enable_caching":{"type":"boolean"},"enable_contract_tests":{"type":"boolean"},"enable_debug_logging":{"description":"Development features","type":"boolean"},"enable_docker_isolation":{"type":"boolean"},"enable_event_bus":{"type":"boolean"},"enable_health_checks":{"type":"boolean"},"enable_metrics":{"type":"boolean"},"enable_oauth":{"description":"Security features","type":"boolean"},"enable_observability":{"description":"Observability features","type":"boolean"},"enable_quarantine":{"type":"boolean"},"enable_runtime":{"description":"Runtime features","type":"boolean"},"enable_search":{"description":"Storage features","type":"boolean"},"enable_sse":{"type":"boolean"},"enable_tracing":{"type":"boolean"},"enable_tray":{"type":"boolean"},"enable_web_ui":{"description":"UI features","type":"boolean"}},"type":"object"},"config.IntentDeclarationConfig":{"description":"Intent declaration settings (Spec 018)","properties":{"strict_server_validation":{"description":"StrictServerValidation controls whether server annotation mismatches\ncause rejection (true) or just warnings (false).\nDefault: true (reject mismatches)","type":"boolean"}},"type":"object"},"config.IsolationConfig":{"description":"Per-server isolation settings","properties":{"enabled":{"description":"Enable Docker isolation for this server (nil = inherit global; legacy, superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments for this server","items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"description":"Custom Docker image (overrides default)","type":"string"},"log_driver":{"description":"Docker log driver override for this server","type":"string"},"log_max_files":{"description":"Maximum number of log files override","type":"string"},"log_max_size":{"description":"Maximum size of log files override","type":"string"},"mode":{"$ref":"#/components/schemas/config.IsolationMode"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"config.IsolationMode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"config.LogConfig":{"description":"Logging configuration","properties":{"compress":{"type":"boolean"},"enable_console":{"type":"boolean"},"enable_file":{"type":"boolean"},"filename":{"type":"string"},"json_format":{"type":"boolean"},"level":{"type":"string"},"log_dir":{"description":"Custom log directory","type":"string"},"max_age":{"description":"days","type":"integer"},"max_backups":{"description":"number of backup files","type":"integer"},"max_size":{"description":"MB","type":"integer"}},"type":"object"},"config.MetricsExporterConfig":{"description":"Metrics gates the Prometheus /metrics scrape endpoint (MCP-32). Disabled\nby default — operators opt in for k8s/enterprise deployments.","properties":{"enabled":{"description":"Enabled exposes /metrics on the existing HTTP listener when true.","type":"boolean"}},"type":"object"},"config.OAuthConfig":{"description":"OAuth configuration (keep even when empty to signal OAuth requirement)","properties":{"client_id":{"type":"string"},"client_secret":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters (e.g., RFC 8707 resource)","type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_uri":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ObservabilityConfig":{"description":"Observability settings (Spec 069): usage aggregate cache/persistence cadence.","properties":{"metrics":{"$ref":"#/components/schemas/config.MetricsExporterConfig"},"tracing":{"$ref":"#/components/schemas/config.TracingExporterConfig"},"usage_cache_ttl":{"description":"UsageCacheTTL bounds the freshness of the usage endpoint's read cache for\nwide windows (FR-005). Default 5s.","type":"string"},"usage_persist_interval":{"description":"UsagePersistInterval is how often the actor-owned usage aggregate snapshot\nis flushed to storage. Default 30s.","type":"string"}},"type":"object"},"config.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.ProfileConfig":{"properties":{"name":{"description":"URL slug, validated","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"deep_scan":{"$ref":"#/components/schemas/config.DeepScanConfig"},"integrity_check_interval":{"type":"string"},"integrity_check_on_restart":{"type":"boolean"},"runtime_read_only":{"type":"boolean"},"runtime_tmpfs_size":{"type":"string"},"scan_timeout_default":{"type":"string"},"scanner_disable_no_new_privileges":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.DisableNoNewPrivileges\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.IsDisableNoNewPrivileges. Cleared after migration.\n\nScannerDisableNoNewPrivileges, when true, omits the\n` + "`" + `--security-opt no-new-privileges` + "`" + ` flag from scanner container runs.\n\nBackground: snap-installed Docker on Ubuntu confines dockerd under the\n` + "`" + `snap.docker.dockerd` + "`" + ` AppArmor profile. When runc tries to transition\nthe container into the inner ` + "`" + `docker-default` + "`" + ` profile to exec the\nentrypoint, AppArmor refuses the transition because NO_NEW_PRIVS\nforbids privilege/profile changes on exec — the result is EPERM\n(\"operation not permitted\") and every scanner fails immediately.\n\nSet this to true ONLY on hosts hitting that incompatibility. Scanner\ncontainers still run with read-only rootfs, tmpfs /tmp, no-network by\ndefault, and read-only source mounts, so the marginal isolation loss\nis small. The preferred fix remains replacing snap docker with a\ndistro-packaged docker.","type":"boolean"},"scanner_fetch_package_source":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.FetchPackageSource\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.EffectiveFetchPackageSource. Cleared after migration.\n\nScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"},"tpa_bundle_path":{"description":"TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json\nthe offline TPA scanner runs (spec 086 FR-019: the signature-DB location\nMUST be configuration-driven, not hardcoded). Empty (the default) runs the\ncorpus embedded in this build.\n\nEnv override: MCPPROXY_TPA_BUNDLE_PATH. Hot-reloadable — the path is\nre-read on every config.reloaded event via\nscanner.Service.ApplySecurityConfig, so a corpus refresh needs no restart.\nA configured bundle that fails to read/parse/version-check/compile is\nREFUSED and the previously active corpus stays live (fail-closed, never\nfail-empty); the reason is logged and surfaced in the security overview's\nsignature_bundle.load_error.","type":"string"}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"init_timeout":{"description":"InitTimeout overrides the global init_timeout for this server's MCP\n` + "`" + `initialize` + "`" + ` handshake deadline (MCP-3322 / GH #760). *Duration tri-state:\nnil = inherit the global value (or 30s default), positive = that deadline.\nResolved by Config.ResolveInitTimeout; validated to {0} ∪ [1s, 30m]. Raise\nthis for upstreams that do legitimate first-run warmup (e.g. caching many\nchannels/users) before responding to ` + "`" + `initialize` + "`" + `.","type":"string"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"launcher_wait_timeout":{"description":"LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched\nHTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted\nwhen the server is configured with both Command and an HTTP/SSE URL — i.e.,\nmcpproxy starts the process AND connects via network. Stdio servers ignore\nthis field. Zero or unset → 30s default.","type":"string"},"max_concurrent_requests":{"description":"Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).\nTri-state per setting, exactly like HealthCheckInterval: absent = inherit\nthe per-server default set (server_concurrency_defaults), explicit 0 =\ndisable that setting for this server (0 max = no per-server limiter at\nall; 0 queue_size = no pending capacity, shed immediately at the cap),\npositive = override. The global aggregate limiter is never inherited from\nhere — it applies on top, so effective concurrency is min(per-server,\nglobal). Resolved by Config.ResolveServerConcurrency.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/config.OAuthConfig"},"protocol":{"description":"stdio, http, sse, streamable-http, auto","type":"string"},"quarantined":{"description":"Security quarantine status","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets a disconnected server","type":"boolean"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"toon_output":{"description":"ToonOutput overrides the global toon_output mode for this server's\ntools (spec 084, FR-001). Plain string, not a pointer: \"\"/absent =\ninherit the global value; \"off\"|\"adaptive\"|\"always\" = override (\"off\"\nis the explicit force-off). Resolved by Config.ResolveToonOutput.","type":"string"},"trust_mode":{"description":"TrustMode is the per-server trust tier: auto|scan|manual. Supersedes\nauto_approve_tool_changes (spec 086). An empty value is derived from the\nlegacy fields at load via normalizeServerQuarantineFlags; the single\nresolution point is EffectiveTrustMode(), which treats an empty or\nunrecognized value as manual (secure by default). Read via\nEffectiveTrustMode(), never the raw string.","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TelemetryConfig":{"description":"Telemetry settings (Spec 036)","properties":{"anonymous_id":{"description":"Auto-generated UUIDv4","type":"string"},"anonymous_id_created_at":{"description":"Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"},"last_reported_version":{"description":"Upgrade funnel","type":"string"},"last_startup_outcome":{"description":"success|port_conflict|db_locked|...","type":"string"},"notice_shown":{"description":"First-run notice flag","type":"boolean"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"config.TracingExporterConfig":{"description":"Tracing gates the OpenTelemetry OTLP trace exporter (MCP-32). Disabled by\ndefault.","properties":{"enabled":{"description":"Enabled turns on OTLP trace export for tool calls and upstream hops.","type":"boolean"},"endpoint":{"description":"Endpoint is the collector address as host:port (no scheme), e.g.\n\"localhost:4318\" for http or \"localhost:4317\" for grpc.","type":"string"},"protocol":{"description":"Protocol selects the OTLP transport: \"http\" or \"grpc\".","type":"string"},"sample_rate":{"description":"SampleRate is the head-based trace sampling ratio in [0,1]. Default 0.1.","type":"number"}},"type":"object"},"config.UpdateCheckConfig":{"description":"Update-check settings (Spec 079 FR-012): config-file control of the\nbackground upgrade-awareness checker (internal/updatecheck). nil =\nenabled on the stable channel (existing default behavior). The existing\nenvironment switches keep working and WIN over these keys (FR-014):\nMCPPROXY_DISABLE_AUTO_UPDATE=true force-disables even when\nenabled=true, and MCPPROXY_ALLOW_PRERELEASE_UPDATES=true force-selects\nthe rc channel even when channel=stable.","properties":{"channel":{"description":"Channel selects which releases are offered as updates: \"stable\"\n(default; prereleases never offered) or \"rc\" (prereleases included).\nEmpty resolves to stable. Validated in ValidateDetailed.\n\nNOTE: for a RELEASED build the running binary's own version is\nauthoritative and overrides this field — a stable build is never\noffered an RC (even with channel=rc), and an RC build always tracks the\nrc channel. This field only takes effect on dev/unstamped builds. See\ninternal/updatecheck.Checker.IncludePrereleases.","type":"string"},"enabled":{"description":"Enabled gates all update checking. Tri-state: nil/absent = enabled\n(default true, matching pre-079 behavior). When false, no network\ncheck is performed and no upgrade nudge appears on any surface\n(FR-015) — /api/v1/info omits the update object entirely.","type":"boolean"}},"type":"object"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\"","type":"string"}},"type":"object"},"connect.ConnectResult":{"description":"The full result; its action mirrors the top-level one","properties":{"action":{"description":"\"created\", \"updated\", \"already_exists\", \"removed\", \"not_found\"","type":"string"},"backup_path":{"type":"string"},"client":{"type":"string"},"config_path":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"arguments":{"description":"Tool call arguments","type":"object"},"detection_types":{"description":"List of detection types found","items":{"type":"string"},"type":"array","uniqueItems":false},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP transport session ID (regenerated on every reconnect)","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\", \"rejected\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"},"work_session_id":{"description":"Spec 082: one client, one project, across reconnects","type":"string"}},"type":"object"},"contracts.ActivitySource":{"description":"How activity was triggered: \"mcp\", \"cli\", \"api\"","type":"string","x-enum-varnames":["ActivitySourceMCP","ActivitySourceCLI","ActivitySourceAPI"]},"contracts.ActivitySummaryResponse":{"properties":{"blocked_count":{"description":"Count of blocked activities","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"rejected_count":{"description":"RejectedCount is the number of calls shed by a concurrency limiter before\nthey reached an upstream (spec 093). Counted separately from errors: it is\nproxy backpressure, not an upstream fault, and it is the signal an\noperator right-sizes max_concurrent_requests against.","type":"integer"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"contracts.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeepScanDescriptor":{"description":"DeepScan reports the opt-in \"deep scan\" layer status (Spec 077 US3),\nSEPARATELY from the baseline verdict above. Always emitted on a computed\nsummary — when deep scan is off (the default) it reports enabled=false\nplus any enabled-but-skipped Docker scanners. It never influences Status.","properties":{"available":{"type":"boolean"},"enabled":{"type":"boolean"},"ran":{"type":"boolean"},"scanners_failed":{"items":{"$ref":"#/components/schemas/contracts.DeepScanScannerFailure"},"type":"array","uniqueItems":false},"skipped_scanners":{"description":"SkippedScanners lists Docker scanners the user enabled that are skipped\nbecause security.deep_scan.enabled is false (informational).","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DeepScanScannerFailure":{"properties":{"id":{"type":"string"},"reason":{"type":"string"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostic":{"description":"Spec 044 — structured diagnostic error and stable error code. Both\nare populated when the server is in a failed state and the error\nhas been classified by internal/diagnostics. Healthy servers omit\nthese fields.","properties":{"cause":{"type":"string"},"code":{"type":"string"},"detected_at":{"type":"string"},"docs_url":{"type":"string"},"fix_steps":{"items":{"$ref":"#/components/schemas/contracts.DiagnosticFixStep"},"type":"array","uniqueItems":false},"severity":{"type":"string"},"user_message":{"type":"string"}},"type":"object"},"contracts.DiagnosticFixStep":{"properties":{"command":{"type":"string"},"destructive":{"type":"boolean"},"fixer_key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.EditRegistrySourceRequest":{"properties":{"name":{"description":"new display name","type":"string"},"servers_url":{"description":"explicit servers-collection URL","type":"string"},"url":{"description":"new base/servers https URL","type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.FindingCounts":{"properties":{"dangerous":{"description":"Tool poisoning, active prompt injection","type":"integer"},"info":{"description":"Low-severity CVEs, informational","type":"integer"},"total":{"type":"integer"},"warning":{"description":"Rug pull, supply chain CVEs with exploits","type":"integer"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object","type":"object"},"config_path":{"description":"Path to config file","type":"string"}},"type":"object"},"contracts.GetRegistriesResponse":{"properties":{"registries":{"items":{"$ref":"#/components/schemas/contracts.Registry"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerLogsResponse":{"properties":{"count":{"type":"integer"},"logs":{"items":{"$ref":"#/components/schemas/contracts.LogEntry"},"type":"array","uniqueItems":false},"server_name":{"type":"string"}},"type":"object"},"contracts.GetServerToolCallsResponse":{"properties":{"server_name":{"type":"string"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerToolsResponse":{"properties":{"count":{"type":"integer"},"server_name":{"type":"string"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GetServersResponse":{"properties":{"servers":{"items":{"$ref":"#/components/schemas/contracts.Server"},"type":"array","uniqueItems":false},"stats":{"$ref":"#/components/schemas/contracts.ServerStats"}},"type":"object"},"contracts.GetSessionDetailResponse":{"properties":{"session":{"$ref":"#/components/schemas/contracts.MCPSession"}},"type":"object"},"contracts.GetSessionsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"sessions":{"items":{"$ref":"#/components/schemas/contracts.MCPSession"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetToolCallDetailResponse":{"properties":{"tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"}},"type":"object"},"contracts.GetToolCallsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GlobalToolsResponse":{"properties":{"failed_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"partial":{"type":"boolean"},"stats":{"$ref":"#/components/schemas/contracts.GlobalToolsStats"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GlobalToolsStats":{"properties":{"disabled":{"type":"integer"},"enabled":{"type":"integer"},"pending_approval":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.HealthStatus":{"description":"Unified health status calculated by the backend","properties":{"action":{"description":"Action is the suggested fix action: \"login\", \"restart\", \"enable\", \"approve\", \"view_logs\", \"set_secret\", \"configure\", or \"\" (none)","type":"string"},"admin_state":{"description":"AdminState indicates the admin state: \"enabled\", \"disabled\", or \"quarantined\"","type":"string"},"detail":{"description":"Detail is an optional longer explanation of the status","type":"string"},"level":{"description":"Level indicates the health level: \"healthy\", \"degraded\", or \"unhealthy\"","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"}},"type":"object"},"contracts.InfoEndpoints":{"description":"Available API endpoints","properties":{"http":{"description":"HTTP endpoint address (e.g., \"127.0.0.1:8080\")","type":"string"},"socket":{"description":"Unix socket path (empty if disabled)","type":"string"}},"type":"object"},"contracts.InfoResponse":{"properties":{"endpoints":{"$ref":"#/components/schemas/contracts.InfoEndpoints"},"launched_by":{"description":"LaunchedBy is the durable launch provenance of the running core (Spec\n092 FR-001a): \"tray\" when a tray spawned it, \"installer\" when the macOS\nPKG postinstall did, \"\" when user-launched or unknown. Always present\n(possibly empty) so a tray can distinguish \"old core, not mine\" from\n\"old core I may supersede\".","type":"string"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"pid":{"description":"PID is the operating-system process id of the running core (Spec 092\nFR-002). A tray that merely ATTACHED to a core holds no Process handle\nfor it, so without this there is no mechanism at all to stop a stale\ncore — the consent action would have nothing to act on and could only\nprint instructions. Paired with LaunchedBy it is what lets a newer tray\nsupersede a core an older tray started.","type":"integer"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"update_policy":{"$ref":"#/components/schemas/contracts.UpdatePolicy"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"network_mode":{"type":"string"},"timeout":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationDefaults":{"description":"IsolationDefaults exposes the resolved baseline values that\nwould apply when no per-server override is set. Populated on\nlist/get responses; never consumed on PATCH requests.","properties":{"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"runtime_type":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.LogEntry":{"properties":{"fields":{"type":"object"},"level":{"type":"string"},"message":{"type":"string"},"server":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.MCPSession":{"properties":{"client_name":{"type":"string"},"client_version":{"type":"string"},"end_time":{"type":"string"},"experimental":{"items":{"type":"string"},"type":"array","uniqueItems":false},"has_roots":{"description":"MCP Client Capabilities","type":"boolean"},"has_sampling":{"type":"boolean"},"id":{"type":"string"},"last_activity":{"type":"string"},"start_time":{"type":"string"},"status":{"type":"string"},"tool_call_count":{"type":"integer"},"total_tokens":{"type":"integer"},"work_session_id":{"type":"string"},"workspace_name":{"description":"Workspace / work session (Spec 082). WorkspaceName is the project's\nbasename — the full local path is never exposed. WorkSessionID groups the\nreconnects that make up one stretch of user work.","type":"string"}},"type":"object"},"contracts.MetadataStatus":{"properties":{"authorization_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"error":{"type":"string"},"found":{"type":"boolean"},"url_checked":{"type":"string"}},"type":"object"},"contracts.MissingSecretInfo":{"properties":{"secret_name":{"type":"string"},"used_by":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.NPMPackageInfo":{"properties":{"exists":{"type":"boolean"},"install_cmd":{"type":"string"}},"type":"object"},"contracts.OAuthConfig":{"properties":{"auth_url":{"type":"string"},"client_id":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_port":{"type":"integer"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_expires_at":{"description":"When the OAuth token expires","type":"string"},"token_url":{"type":"string"},"token_valid":{"description":"Whether token is currently valid","type":"boolean"}},"type":"object"},"contracts.OAuthErrorDetails":{"description":"Structured discovery/failure details","properties":{"authorization_server_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"dcr_status":{"$ref":"#/components/schemas/contracts.DCRStatus"},"protected_resource_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"server_url":{"type":"string"}},"type":"object"},"contracts.OAuthFlowError":{"properties":{"correlation_id":{"description":"Flow tracking ID for log correlation","type":"string"},"debug_hint":{"description":"CLI command for log lookup","type":"string"},"details":{"$ref":"#/components/schemas/contracts.OAuthErrorDetails"},"error_code":{"description":"Machine-readable error code (e.g., OAUTH_NO_METADATA)","type":"string"},"error_type":{"description":"Category of OAuth runtime failure","type":"string"},"message":{"description":"Human-readable error description","type":"string"},"request_id":{"description":"HTTP request ID (from PR #237)","type":"string"},"server_name":{"description":"Server that failed OAuth","type":"string"},"success":{"description":"Always false","type":"boolean"},"suggestion":{"description":"Actionable remediation hint","type":"string"}},"type":"object"},"contracts.OAuthIssue":{"properties":{"documentation_url":{"type":"string"},"error":{"type":"string"},"issue":{"type":"string"},"missing_params":{"items":{"type":"string"},"type":"array","uniqueItems":false},"resolution":{"type":"string"},"server_name":{"type":"string"}},"type":"object"},"contracts.OAuthRequirement":{"properties":{"expires_at":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"state":{"type":"string"}},"type":"object"},"contracts.OAuthStartResponse":{"properties":{"auth_url":{"description":"Authorization URL (always included for manual use)","type":"string"},"browser_error":{"description":"Error message if browser launch failed","type":"string"},"browser_opened":{"description":"Whether browser launch succeeded","type":"boolean"},"correlation_id":{"description":"UUID for tracking this flow","type":"string"},"message":{"description":"Human-readable status message","type":"string"},"server_name":{"description":"Name of the server being authenticated","type":"string"},"success":{"description":"Always true for successful start","type":"boolean"}},"type":"object"},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"blocked_count":{"description":"Number of disabled (blocked) tools","type":"integer"},"changed_count":{"description":"Number of tools whose description/schema changed since approval","type":"integer"},"pending_count":{"description":"Number of newly discovered tools awaiting approval","type":"integer"}},"type":"object"},"contracts.RefreshRegistryResponse":{"properties":{"cleared":{"description":"number of cached entries dropped","type":"integer"},"registry_id":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"deep_scan":{"$ref":"#/components/schemas/contracts.DeepScanDescriptor"},"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary (baseline) scan pass — informational only.\nSpec 077 US3 (FR-008/FR-014): Status is derived SOLELY from the\ndeterministic baseline findings; a failed Docker deep scanner no longer\ndowngrades a clean verdict. That failure is surfaced via DeepScan instead.","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"init_timeout":{"description":"InitTimeout mirrors config.ServerConfig.InitTimeout (MCP-3322 / GH #760):\nthe per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override. Serialized as\na duration string (e.g. \"120s\"); nil/omitted means \"inherit the global\ndefault\". Surfaced on the GET path so clients can read back a configured\noverride; PATCH/POST accept it via AddServerRequest.","type":"string"},"isolation":{"$ref":"#/components/schemas/contracts.IsolationConfig"},"isolation_defaults":{"$ref":"#/components/schemas/contracts.IsolationDefaults"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"max_concurrent_requests":{"description":"Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of\nFR-020. Each setting is tri-state: nil (omitted) means \"inherit\nserver_concurrency_defaults\", 0 disables that setting for this server,\npositive overrides it. Surfaced on the GET path so a caller can read back\nwhat it set; PATCH/POST accept them via AddServerRequest. The effective\nconcurrency for a server is additionally bounded by the global aggregate\nlimiter, which is NOT an inheritance source for these fields.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"security_scan":{"$ref":"#/components/schemas/contracts.SecurityScanSummary"},"should_retry":{"type":"boolean"},"source_registry_id":{"description":"MCP-901 — registry provenance of an upstream that was added from a\nregistry. SourceRegistryID names the source registry (empty for\nmanually-configured servers); SourceRegistryProvenance is the trust tag\nrecorded at add time (\"official/trusted\" or \"custom/unverified\"). Both\nare projected from config.ServerConfig so the approval/quarantine view\ncan render an \"added from \u003cregistry\u003e · unverified\" origin badge. Optional\nand omitted when empty — clients that pre-date this treat them as absent.","type":"string"},"source_registry_provenance":{"type":"string"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"trust_mode":{"description":"TrustMode mirrors config.ServerConfig.TrustMode (spec 086): the per-server\ntrust tier (\"auto\"/\"scan\"/\"manual\"). Surfaced on the GET path so clients can\nread back the persisted mode; PATCH/POST accept it via AddServerRequest.\nOmitted when empty (server predates the field / relies on legacy flags).","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"user_logged_out":{"description":"True if user explicitly logged out (prevents auto-reconnection)","type":"boolean"},"working_dir":{"type":"string"}},"type":"object"},"contracts.ServerActionResponse":{"properties":{"action":{"type":"string"},"async":{"type":"boolean"},"server":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ServerStats":{"properties":{"connected_servers":{"type":"integer"},"docker_containers":{"type":"integer"},"quarantined_servers":{"type":"integer"},"token_metrics":{"$ref":"#/components/schemas/contracts.ServerTokenMetrics"},"total_servers":{"type":"integer"},"total_tools":{"type":"integer"}},"type":"object"},"contracts.ServerTokenMetrics":{"properties":{"average_query_result_size":{"description":"Typical retrieve_tools output (tokens)","type":"integer"},"per_server_tool_list_sizes":{"additionalProperties":{"type":"integer"},"description":"Token size per server","type":"object"},"saved_tokens":{"description":"Difference","type":"integer"},"saved_tokens_percentage":{"description":"Percentage saved","type":"number"},"total_server_tool_list_size":{"description":"All upstream tools combined (tokens)","type":"integer"}},"type":"object"},"contracts.SuccessResponse":{"properties":{"data":{"type":"object"},"success":{"type":"boolean"}},"type":"object"},"contracts.TokenMetrics":{"description":"Token usage metrics (nil for older records)","properties":{"encoding":{"description":"Encoding used (e.g., cl100k_base)","type":"string"},"estimated_cost":{"description":"Optional cost estimate","type":"number"},"input_tokens":{"description":"Tokens in the request","type":"integer"},"model":{"description":"Model used for tokenization","type":"string"},"output_tokens":{"description":"Tokens in the response","type":"integer"},"total_tokens":{"description":"Total tokens (input + output)","type":"integer"},"truncated_tokens":{"description":"Tokens removed by truncation","type":"integer"},"was_truncated":{"description":"Whether response was truncated","type":"boolean"}},"type":"object"},"contracts.Tool":{"properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"approval_status":{"type":"string"},"config_denied":{"description":"ConfigDenied is true when the tool is denied by the server's static\nenabled_tools / disabled_tools config. The user cannot override this toggle.","type":"boolean"},"description":{"type":"string"},"disabled":{"description":"Disabled mirrors ToolApprovalRecord.Disabled so per-tool enable state is\navailable without a second round-trip to the approvals endpoint. Absent\nin the JSON when false (default) to keep responses compact.","type":"boolean"},"held_reason":{"description":"HeldReason, HeldVerdict and HeldSignals mirror the same-named fields on\nstorage.ToolApprovalRecord: the offline-scan evidence that made\ntrust_mode: scan hold this tool for review (spec 086 FR-018). HeldSignals\nnames the matched deterministic check ids, e.g.\n\"tpa.TPA-2026-0001.hidden_instruction\", so a reviewer can see WHY the tool\nis held. All three are omitted for tools that are not held by the scan gate\n(including every record written before the field existed).","type":"string"},"held_signals":{"items":{"type":"string"},"type":"array","uniqueItems":false},"held_verdict":{"type":"string"},"last_used":{"type":"string"},"name":{"type":"string"},"schema":{"type":"object"},"server_name":{"type":"string"},"usage":{"type":"integer"}},"type":"object"},"contracts.ToolAnnotation":{"description":"Tool behavior hints snapshot","properties":{"destructiveHint":{"type":"boolean"},"idempotentHint":{"type":"boolean"},"openWorldHint":{"type":"boolean"},"readOnlyHint":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"contracts.ToolCallRecord":{"description":"The new tool call record","properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"arguments":{"description":"Tool arguments","type":"object"},"config_path":{"description":"Active config file path","type":"string"},"duration":{"description":"Duration in nanoseconds","type":"integer"},"error":{"description":"Error message (failure only)","type":"string"},"execution_type":{"description":"\"direct\" or \"code_execution\"","type":"string"},"id":{"description":"Unique identifier","type":"string"},"mcp_client_name":{"description":"MCP client name from InitializeRequest","type":"string"},"mcp_client_version":{"description":"MCP client version","type":"string"},"mcp_session_id":{"description":"MCP session identifier","type":"string"},"metrics":{"$ref":"#/components/schemas/contracts.TokenMetrics"},"parent_call_id":{"description":"Links nested calls to parent code_execution","type":"string"},"request_id":{"description":"Request correlation ID","type":"string"},"response":{"description":"Tool response (success only)","type":"object"},"server_id":{"description":"Server identity hash","type":"string"},"server_name":{"description":"Human-readable server name","type":"string"},"timestamp":{"description":"When the call was made","type":"string"},"tool_name":{"description":"Tool name (without server prefix)","type":"string"}},"type":"object"},"contracts.UpdateInfo":{"description":"Update information (if available)","properties":{"available":{"description":"Whether an update is available","type":"boolean"},"check_error":{"description":"Error message if update check failed","type":"string"},"checked_at":{"description":"When the update check was performed","type":"string"},"install_channel":{"description":"Detected install channel (homebrew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown) — Spec 079 FR-008","type":"string"},"is_prerelease":{"description":"Whether the latest version is a prerelease","type":"boolean"},"latest_version":{"description":"Latest version available (e.g., \"v1.2.3\")","type":"string"},"nudges_suppressed":{"description":"UI surfaces must stay quiet (CI / non-interactive context); machine-readable fields still report the facts — Spec 079 FR-019","type":"boolean"},"release_url":{"description":"URL to the release page","type":"string"},"update_command":{"description":"One-line update command for the channel; only set when an update is available and the channel has one — Spec 079 FR-009","type":"string"}},"type":"object"},"contracts.UpdatePolicy":{"description":"UpdatePolicy is the effective, hot-reloadable update policy (Spec 092\nFR-015). Always present: the ` + "`" + `update` + "`" + ` object above is omitted both when\nupdate checking is disabled AND when no check has produced a result\nyet, so its absence cannot tell a client whether it is allowed to run\nits own (e.g. Sparkle feed) check. This field states the answer.","properties":{"channel":{"description":"Channel is the tracked release channel: \"stable\" or \"rc\".","type":"string"},"enabled":{"description":"Enabled is the effective automatic-check kill switch: update_check.enabled\nwith MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated\n\"Check for Updates\" stays available regardless.","type":"boolean"},"nudges_suppressed":{"description":"NudgesSuppressed asks UI surfaces to stay quiet (CI / non-interactive)\nwhile machine-readable fields keep reporting the facts.","type":"boolean"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.UsageAggregateResponse":{"properties":{"freshness_ms":{"description":"age of the underlying snapshot in ms","type":"integer"},"generated_at":{"type":"string"},"other":{"$ref":"#/components/schemas/contracts.UsageOtherBucket"},"timeline":{"items":{"$ref":"#/components/schemas/contracts.UsageTimeBucket"},"type":"array","uniqueItems":false},"token_source":{"description":"\"bytes\" (size-based proxy, FR-006)","type":"string"},"tokens_saved":{"description":"echoed from ServerTokenMetrics (FR-007)","type":"integer"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"window":{"type":"string"}},"type":"object"},"contracts.UsageOtherBucket":{"description":"present only when the list was truncated to top-N","properties":{"calls":{"type":"integer"},"tools_folded":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageTimeBucket":{"properties":{"calls":{"type":"integer"},"errors":{"type":"integer"},"start":{"type":"string"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageToolStat":{"properties":{"avg_req_bytes":{"description":"null when no sized request calls","type":"integer"},"avg_resp_bytes":{"description":"null when sized_calls == 0 (only legacy 0-byte calls)","type":"integer"},"blocked":{"type":"integer"},"calls":{"type":"integer"},"error_rate":{"type":"number"},"errors":{"type":"integer"},"last_used":{"type":"string"},"p50_ms":{"type":"integer"},"p95_ms":{"type":"integer"},"rejected":{"description":"spec 093: shed by a concurrency limit; never executed, so excluded from calls/latency","type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"init_timeout":{"description":"InitTimeout is the per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override\n(MCP-3322 / GH #760), serialized as a duration string (e.g. \"120s\"). A nil\npointer means \"leave unchanged\" on PATCH; a present value is applied.\nMirrors config.ServerConfig.InitTimeout's *Duration tri-state.","type":"string"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"max_concurrent_requests":{"description":"MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server\nconcurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is\ntri-state: a nil pointer means \"leave unchanged\" on PATCH and \"inherit\nserver_concurrency_defaults\" on create; an explicit 0 disables that\nsetting for this server; a positive value overrides it. Do NOT collapse\nthem to plain values — an omitted field would then silently reset a\nconfigured limit.","type":"integer"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"type":"boolean"},"trust_mode":{"description":"TrustMode is the per-server trust tier (spec 086): \"auto\", \"scan\", or\n\"manual\". Empty means \"leave unchanged\" on PATCH (and inherit the migrated\ndefault on create). A non-empty value is applied to ServerConfig.TrustMode\nand resolved by EffectiveTrustMode (an unrecognized value fails closed to\nmanual). This is the REST seam for changing the trust tier via\nPOST/PATCH /api/v1/servers.","type":"string"},"url":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.CanonicalConfigPath":{"properties":{"description":{"description":"Brief description","type":"string"},"exists":{"description":"Whether the file exists","type":"boolean"},"format":{"description":"Format identifier (e.g., \"claude_desktop\")","type":"string"},"name":{"description":"Display name (e.g., \"Claude Desktop\")","type":"string"},"os":{"description":"Operating system (darwin, windows, linux)","type":"string"},"path":{"description":"Full path to the config file","type":"string"}},"type":"object"},"httpapi.CanonicalConfigPathsResponse":{"properties":{"os":{"description":"Current operating system","type":"string"},"paths":{"description":"List of canonical config paths","items":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPath"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ConnectConflictResponse":{"properties":{"action":{"description":"already_exists | precondition_failed","type":"string"},"data":{"$ref":"#/components/schemas/connect.ConnectResult"},"error":{"description":"Human-readable message","type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"precondition_token":{"description":"PreconditionToken is the opaque token from the preview this write was\nconfirmed against (Spec 091 FR-005). When present, the core rechecks it\nat write time and responds 409 with action \"precondition_failed\" —\nwriting nothing — if the config or the entry MCPProxy would write has\ndrifted since; the caller then re-previews instead of retrying. Absent\nmeans exactly the pre-091 behavior. A replace-classified flow sends this\nTOGETHER with force=true: the token, not the absence of force, is the\noverwrite safety.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.ImportFromPathRequest":{"properties":{"format":{"description":"Optional format hint","type":"string"},"path":{"description":"File path to import from","type":"string"},"rename":{"additionalProperties":{"type":"string"},"description":"Rename maps a server name → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys are matched against\neither the raw source name (OriginalName) or the sanitized name shown\nin the preview (Server.Name); these differ for names that need\nsanitizing (e.g. \"Figma Desktop\" → \"Figma_Desktop\"). Keys not present\nin the imported set are ignored.","type":"object"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportRequest":{"properties":{"content":{"description":"Raw JSON or TOML content","type":"string"},"format":{"description":"Optional format hint","type":"string"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportResponse":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/configimport.FailedServer"},"type":"array","uniqueItems":false},"format":{"type":"string"},"format_name":{"type":"string"},"imported":{"items":{"$ref":"#/components/schemas/httpapi.ImportedServerResponse"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/configimport.SkippedServer"},"type":"array","uniqueItems":false},"summary":{"$ref":"#/components/schemas/configimport.ImportSummary"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportedServerResponse":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (image,\nnetwork_mode, extra_args, working_dir, enabled). A nil pointer\nmeans \"do not touch isolation config\"; an empty-but-present\nobject on PATCH intentionally clears the overrides.","properties":{"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.OnboardingMarkRequest":{"properties":{"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value. The stored enum is wider (Spec 080\nFR-001): a \"skipped\" request for a previously untouched connect step\nis upgraded server-side to \"completed_external\" when the install\nshows positive evidence of an external connection (Spec 080 FR-002).\n\"completed_external\" is NOT accepted from clients — it must never be\npersisted without that server-verified evidence (edge case: \"never\nguess completed_external without positive evidence\").","type":"string"},"engaged":{"description":"Engaged marks the wizard as engaged (completed or explicitly skipped).\nOnce true, the wizard does not auto-show again.","type":"boolean"},"mark_shown":{"description":"MarkShown records the wizard's first display time if not already set.","type":"boolean"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","type":"string"}},"type":"object"},"httpapi.SetActiveProfileRequest":{"properties":{"active_profile":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.UndoConnectRequest":{"properties":{"backup_name":{"description":"BackupName is the bare filename (filepath.Base) of the backup returned as\nbackup_path by the preceding connect — a name, never a path. Undo resolves\nthe full path server-side by joining it with the client's own config\ndirectory, so a client-supplied value can never contribute a directory\ncomponent (traversal is impossible by construction). Empty means the\nconnect created the file (no prior file existed), so undo removes it.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.UpdateFailureRequest":{"properties":{"stage":{"description":"Stage is the failure stage of the update session.","enum":["appcast","download","install","other"],"type":"string"}},"type":"object"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned upstream servers (MCP-2769). It is OFF by\ndefault and deliberately kept out of the AllowedSystemVars default list:\nproxy URLs frequently carry credentials (http://user:pass@proxy), so\nforwarding them to every stdio upstream is a credential-leak risk. When\nenabled, values are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"},"telemetry.FeedbackContext":{"properties":{"arch":{"type":"string"},"connected_server_count":{"type":"integer"},"edition":{"type":"string"},"os":{"type":"string"},"routing_mode":{"type":"string"},"server_count":{"type":"integer"},"version":{"type":"string"}},"type":"object"},"telemetry.FeedbackRequest":{"properties":{"category":{"description":"bug, feature, other","type":"string"},"context":{"$ref":"#/components/schemas/telemetry.FeedbackContext"},"email":{"type":"string"},"message":{"type":"string"}},"type":"object"},"telemetry.FeedbackResponse":{"properties":{"error":{"type":"string"},"issue_url":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"description":"API key authentication via query parameter. Use ?apikey=your-key","in":"query","name":"apikey","type":"apiKey"}}}, + "components": {"schemas":{"config.ConcurrencyDefaults":{"description":"ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server\ndefault set inherited by every server that does not override a setting.\nAbsent (the default) = no per-server limiting unless a server configures\nit explicitly. File/API-configured only — no env scheme (FR-022).","properties":{"max_concurrent_requests":{"type":"integer"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"}},"type":"object"},"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_max_size_mb":{"description":"Max total activity-log size in MB before pruning oldest (default: 256, 0=disabled)","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"allow_private_registry_fetch":{"description":"AllowPrivateRegistryFetch opts out of the registry SSRF guard (MCP-1076,\nCWE-918). By default (false) registry fetches refuse any host that is — or\nresolves to — a non-routable address (loopback, RFC1918/CGNAT private,\nlink-local incl. the 169.254.169.254 cloud-metadata endpoint), so a\nmalicious or typo'd registry source cannot turn the daemon into a\nrequest-forgery vector against internal services.\n\nThis opt-out is BLANKET (all-or-nothing): setting it true disables the\nguard for EVERY non-routable range at once — loopback, RFC1918/CGNAT\nprivate, link-local AND the 169.254.169.254 cloud-metadata endpoint. There\nis no way to allow only loopback; enabling it for a localhost dev registry\nalso re-opens the cloud-metadata SSRF vector. Set true ONLY when you\nintentionally run a trusted registry mirror on an internal/private address,\nideally on a host with no cloud-metadata exposure. The change takes effect\nonly on daemon (re)start or config reload.","type":"boolean"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"api_key":{"description":"Security settings","type":"string"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_parallel":{"description":"Default concurrency for call_tools() batches (1-32, default: 8)","type":"integer"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"disable_management":{"type":"boolean"},"docker_isolation":{"$ref":"#/components/schemas/config.DockerIsolationConfig"},"docker_recovery":{"$ref":"#/components/schemas/config.DockerRecoveryConfig"},"enable_code_execution":{"description":"Code execution settings","type":"boolean"},"enable_prompts":{"description":"Prompts settings","type":"boolean"},"enable_socket":{"description":"Enable Unix socket/named pipe for local IPC (default: true)","type":"boolean"},"enable_tray":{"description":"Deprecated: EnableTray is unused and has no runtime effect. Kept for backward compatibility.","type":"boolean"},"environment":{"$ref":"#/components/schemas/secureenv.EnvConfig"},"features":{"$ref":"#/components/schemas/config.FeatureFlags"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"http_idle_timeout":{"description":"HTTPIdleTimeout caps how long an idle keep-alive connection is kept open.\nUnset = 180s. \"0s\" removes the dedicated idle deadline, but net/http then\nfalls back to ReadTimeout — idle is fully unbounded only when\nhttp_read_timeout is also \"0s\". Requires a restart.","type":"string"},"http_read_timeout":{"description":"HTTPReadTimeout caps how long reading a whole request (headers + body)\nmay take. Unset = 120s; \"0s\" disables it. Requires a restart.","type":"string"},"http_write_timeout":{"description":"HTTPWriteTimeout caps how long producing a whole response may take on\nnon-streaming endpoints (REST, Web UI, health). Unset = 120s; \"0s\"\ndisables it globally. MCP and SSE /events routes are exempt by design.","type":"string"},"init_timeout":{"description":"InitTimeout is the global default deadline for an upstream's MCP\n` + "`" + `initialize` + "`" + ` handshake (MCP-3322 / GH #760). *Duration tri-state: nil =\ninherit the built-in 30s default; a positive value = that deadline. A\nper-server InitTimeout overrides this. Resolved by ResolveInitTimeout;\nvalidated to {0} ∪ [1s, 30m] in Validate(). Servers doing legitimate\nfirst-run warmup (cache/index build) before answering ` + "`" + `initialize` + "`" + ` can\nraise this so they are not killed mid-startup.","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_concurrent_requests":{"description":"Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL\nAGGREGATE limiter — one proxy-wide cap on concurrently running upstream\ntool calls, with its own bounded wait queue. Tri-state pointers: absent =\nthe limiter does not exist (default, zero behavior change); an explicit 0\nmax also disables it; positive = that cap. This scope is NEVER a\nper-server inheritance source — per-server values come from\nServerConcurrencyDefaults / the per-server overrides — but a server's\neffective concurrency is bounded by BOTH its own limiter and this one.\nResolved by ResolveGlobalConcurrency; hot-reloadable; overridable via\nMCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).","type":"integer"},"max_result_size_chars":{"description":"Advertised on every tool as ` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; raises Claude Code's inline-response ceiling from 50k to up to 500k chars. Set to 0 to disable.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of the\nsecret-bearing server fields — sensitive header values (Authorization,\nX-API-Key, Cookie, …), env-var secrets, and URL query credentials — in\nresponses from the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + `\nREST API, and the SSE event stream. It also lets URL secrets echoed\ninto last_error / health.detail through unscrubbed.\n\nDefault false — sensitive values are surfaced masked as\n` + "`" + `••••\u003clast2\u003e (\u003cN\u003e chars)` + "`" + ` (error strings use ` + "`" + `***REDACTED***` + "`" + `) so an\nMCP agent cannot read Bearer tokens / API keys / URL secrets out of\nanother upstream's config (PR #425, issue #872). ${env:…}/${keyring:…}\nreferences are labels, not secrets, and pass through unchanged.\n\nThe Web UI / macOS tray edit forms work without seeing the real\nvalues: PATCH /api/v1/servers/{id} deep-merges (omitted keys are\npreserved, see ` + "`" + `headers_remove` + "`" + ` / ` + "`" + `env_remove` + "`" + ` for explicit\ndeletes), so clients compute a diff and only send the keys that\nactually changed. Redacted-but-unchanged values never round-trip\n— the backend keeps the real string. Set this to true if a\ndownstream tool genuinely needs raw values in the response.","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"security":{"$ref":"#/components/schemas/config.SecurityConfig"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"server_concurrency_defaults":{"$ref":"#/components/schemas/config.ConcurrencyDefaults"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_discovery_interval":{"type":"string"},"tool_response_limit":{"type":"integer"},"tool_response_mode":{"description":"Tool response mode (Spec 085): how retrieve_tools serializes results.\nValid values: \"\" (= full), \"full\" (default: today's schema-bearing\nentries), \"compact\" (signature + first-sentence entries). Orthogonal to\nrouting_mode — routing_mode selects the tool SURFACE, this selects the\nSERIALIZATION within the retrieve_tools surface. Serialization-only: it\nnever affects the query, ranking, or result set. Hot-reloadable.","type":"string"},"tool_response_session_risk_warning":{"description":"ToolResponseSessionRiskWarning controls whether the prose ` + "`" + `warning` + "`" + ` field\nis included in the ` + "`" + `session_risk` + "`" + ` object returned by ` + "`" + `retrieve_tools` + "`" + `.\nThe structured fields (level, lethal_trifecta, has_open_world_tools, etc.)\nare always included. Default: false (quiet for LLM clients) — see issue #406.\nMost tools lack annotations, so the MCP-spec defaults treat them as fully\npermissive across all three risk axes, which makes the prose warning fire\non almost every call and wastes tokens.","type":"boolean"},"tools_limit":{"type":"integer"},"toon_min_savings_pct":{"description":"ToonMinSavingsPct is the minimum byte-savings percentage (validated\n1-90; 0/unset → 15) the complete TOON emission (marker + hint + body)\nmust achieve over the exact passthrough emission for adaptive mode to\nencode a block. Byte savings approximate token savings for the tabular\npayload class; the spec-083 profiler reports true token deltas.\nGlobal-only (no per-server override, FR-001).","type":"integer"},"toon_output":{"description":"ToonOutput selects the TOON encoding mode for call_tool_* result text\nblocks (spec 084): \"off\" (default — responses byte-identical to\npre-feature behavior), \"adaptive\" (encode only tabular-uniform payloads\nthat beat compact JSON by ToonMinSavingsPct), or \"always\"\n(benchmark/debug only — encodes every JSON-parseable block and can\nINCREASE token cost). Per-server override: ServerConfig.ToonOutput.\nResolved by ResolveToonOutput; hot-reloadable.","type":"string"},"top_k":{"description":"Deprecated: TopK is superseded by ToolsLimit and has no runtime effect. Kept for backward compatibility.","type":"integer"},"tray_endpoint":{"description":"Tray endpoint override (unix:// or npipe://)","type":"string"},"trusted_hosts":{"description":"TrustedHosts lists non-loopback Host header values accepted on loopback\nlisteners (GH #898). DNS-rebinding protection rejects requests whose Host\nheader is not a loopback address when mcpproxy listens on loopback; a\nreverse proxy (nginx → 127.0.0.1) forwarding the public domain in Host\ntrips it. Entries are hostnames, case-insensitive; an entry without a\nport matches any port, with a port it must match exactly; a leading dot\n(\".example.com\") is a subdomain wildcard. The single entry \"*\" disables\nHost and Origin validation entirely. The same list also validates the\nOrigin header when present (MCP spec DNS-rebinding defense). Empty\n(default) keeps full protection. Env override: MCPPROXY_TRUSTED_HOSTS\n(comma-separated).","items":{"type":"string"},"type":"array","uniqueItems":false},"update_check":{"$ref":"#/components/schemas/config.UpdateCheckConfig"}},"type":"object"},"config.CustomPattern":{"properties":{"category":{"description":"Category (defaults to \"custom\")","type":"string"},"keywords":{"description":"Keywords to match (mutually exclusive with Regex)","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Unique identifier for this pattern","type":"string"},"regex":{"description":"Regex pattern (mutually exclusive with Keywords)","type":"string"},"severity":{"description":"Risk level: critical, high, medium, low","type":"string"}},"type":"object"},"config.DeepScanConfig":{"description":"DeepScan is the opt-in \"deep scan\" layer (Spec 077 US3). It subsumes the\ndeprecated top-level scanner_fetch_package_source / scanner_disable_no_new_privileges\nkeys (migrated on load) and gates the heavy Docker-based scanners + source\nextraction. Disabled by default (FR-006): only the deterministic in-process\nbaseline scanner runs. A deep-scan failure NEVER changes the baseline verdict\n(FR-007/FR-008).","properties":{"disable_no_new_privileges":{"description":"DisableNoNewPrivileges, when true, omits the ` + "`" + `--security-opt\nno-new-privileges` + "`" + ` flag from scanner container runs (snap-docker/AppArmor\nescape hatch). Absorbs the deprecated top-level\nscanner_disable_no_new_privileges. Default false.","type":"boolean"},"enabled":{"description":"Enabled is the master opt-in for the heavy layer (FR-006). Default false.","type":"boolean"},"fetch_package_source":{"description":"FetchPackageSource controls whether the scanner fetches the PUBLISHED\nsource of package-runner servers (npx/uvx) — without executing it — when\nno local source is available. Absorbs the deprecated top-level\nscanner_fetch_package_source. Default (nil) is ENABLED within deep scan.","type":"boolean"},"scanners":{"description":"Scanners optionally restricts which deep scanners may run under the\numbrella (by scanner id). Empty ⇒ all enabled deep scanners are eligible.","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.DockerIsolationConfig":{"description":"Docker isolation settings","properties":{"cpu_limit":{"description":"CPU limit for containers","type":"string"},"default_images":{"additionalProperties":{"type":"string"},"description":"Map of runtime type to Docker image","type":"object"},"enable_cache_volume":{"description":"Mount shared cache volumes for faster restarts (default: true)","type":"boolean"},"enabled":{"description":"Global enable/disable for Docker isolation (legacy; superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments","items":{"type":"string"},"type":"array","uniqueItems":false},"log_driver":{"description":"Docker log driver (default: json-file)","type":"string"},"log_max_files":{"description":"Maximum number of log files (default: 3)","type":"string"},"log_max_size":{"description":"Maximum size of log files (default: 100m)","type":"string"},"memory_limit":{"description":"Memory limit for containers","type":"string"},"mode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"network_mode":{"description":"Docker network mode (default: bridge)","type":"string"},"registry":{"description":"Custom registry (defaults to docker.io)","type":"string"},"timeout":{"description":"Container startup timeout","type":"string"}},"type":"object"},"config.DockerRecoveryConfig":{"description":"Docker recovery settings","properties":{"enabled":{"description":"Enable Docker recovery monitoring (default: true)","type":"boolean"},"max_retries":{"description":"Maximum retry attempts (0 = unlimited)","type":"integer"},"notify_on_failure":{"description":"Show notification on recovery failure (default: true)","type":"boolean"},"notify_on_retry":{"description":"Show notification on each retry (default: false)","type":"boolean"},"notify_on_start":{"description":"Show notification when recovery starts (default: true)","type":"boolean"},"notify_on_success":{"description":"Show notification on successful recovery (default: true)","type":"boolean"},"persistent_state":{"description":"Save recovery state across restarts (default: true)","type":"boolean"}},"type":"object"},"config.FeatureFlags":{"description":"Deprecated: Features flags are unused and have no runtime effect. Kept for backward compatibility.","properties":{"enable_async_storage":{"type":"boolean"},"enable_caching":{"type":"boolean"},"enable_contract_tests":{"type":"boolean"},"enable_debug_logging":{"description":"Development features","type":"boolean"},"enable_docker_isolation":{"type":"boolean"},"enable_event_bus":{"type":"boolean"},"enable_health_checks":{"type":"boolean"},"enable_metrics":{"type":"boolean"},"enable_oauth":{"description":"Security features","type":"boolean"},"enable_observability":{"description":"Observability features","type":"boolean"},"enable_quarantine":{"type":"boolean"},"enable_runtime":{"description":"Runtime features","type":"boolean"},"enable_search":{"description":"Storage features","type":"boolean"},"enable_sse":{"type":"boolean"},"enable_tracing":{"type":"boolean"},"enable_tray":{"type":"boolean"},"enable_web_ui":{"description":"UI features","type":"boolean"}},"type":"object"},"config.IntentDeclarationConfig":{"description":"Intent declaration settings (Spec 018)","properties":{"strict_server_validation":{"description":"StrictServerValidation controls whether server annotation mismatches\ncause rejection (true) or just warnings (false).\nDefault: true (reject mismatches)","type":"boolean"}},"type":"object"},"config.IsolationConfig":{"description":"Per-server isolation settings","properties":{"enabled":{"description":"Enable Docker isolation for this server (nil = inherit global; legacy, superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments for this server","items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"description":"Custom Docker image (overrides default)","type":"string"},"log_driver":{"description":"Docker log driver override for this server","type":"string"},"log_max_files":{"description":"Maximum number of log files override","type":"string"},"log_max_size":{"description":"Maximum size of log files override","type":"string"},"mode":{"$ref":"#/components/schemas/config.IsolationMode"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"config.IsolationMode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"config.LogConfig":{"description":"Logging configuration","properties":{"compress":{"type":"boolean"},"enable_console":{"type":"boolean"},"enable_file":{"type":"boolean"},"filename":{"type":"string"},"json_format":{"type":"boolean"},"level":{"type":"string"},"log_dir":{"description":"Custom log directory","type":"string"},"max_age":{"description":"days","type":"integer"},"max_backups":{"description":"number of backup files","type":"integer"},"max_size":{"description":"MB","type":"integer"}},"type":"object"},"config.MetricsExporterConfig":{"description":"Metrics gates the Prometheus /metrics scrape endpoint (MCP-32). Disabled\nby default — operators opt in for k8s/enterprise deployments.","properties":{"enabled":{"description":"Enabled exposes /metrics on the existing HTTP listener when true.","type":"boolean"}},"type":"object"},"config.OAuthConfig":{"description":"OAuth configuration (keep even when empty to signal OAuth requirement)","properties":{"client_id":{"type":"string"},"client_secret":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters (e.g., RFC 8707 resource)","type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_uri":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ObservabilityConfig":{"description":"Observability settings (Spec 069): usage aggregate cache/persistence cadence.","properties":{"metrics":{"$ref":"#/components/schemas/config.MetricsExporterConfig"},"tracing":{"$ref":"#/components/schemas/config.TracingExporterConfig"},"usage_cache_ttl":{"description":"UsageCacheTTL bounds the freshness of the usage endpoint's read cache for\nwide windows (FR-005). Default 5s.","type":"string"},"usage_persist_interval":{"description":"UsagePersistInterval is how often the actor-owned usage aggregate snapshot\nis flushed to storage. Default 30s.","type":"string"}},"type":"object"},"config.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.ProfileConfig":{"properties":{"name":{"description":"URL slug, validated","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"deep_scan":{"$ref":"#/components/schemas/config.DeepScanConfig"},"integrity_check_interval":{"type":"string"},"integrity_check_on_restart":{"type":"boolean"},"runtime_read_only":{"type":"boolean"},"runtime_tmpfs_size":{"type":"string"},"scan_timeout_default":{"type":"string"},"scanner_disable_no_new_privileges":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.DisableNoNewPrivileges\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.IsDisableNoNewPrivileges. Cleared after migration.\n\nScannerDisableNoNewPrivileges, when true, omits the\n` + "`" + `--security-opt no-new-privileges` + "`" + ` flag from scanner container runs.\n\nBackground: snap-installed Docker on Ubuntu confines dockerd under the\n` + "`" + `snap.docker.dockerd` + "`" + ` AppArmor profile. When runc tries to transition\nthe container into the inner ` + "`" + `docker-default` + "`" + ` profile to exec the\nentrypoint, AppArmor refuses the transition because NO_NEW_PRIVS\nforbids privilege/profile changes on exec — the result is EPERM\n(\"operation not permitted\") and every scanner fails immediately.\n\nSet this to true ONLY on hosts hitting that incompatibility. Scanner\ncontainers still run with read-only rootfs, tmpfs /tmp, no-network by\ndefault, and read-only source mounts, so the marginal isolation loss\nis small. The preferred fix remains replacing snap docker with a\ndistro-packaged docker.","type":"boolean"},"scanner_fetch_package_source":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.FetchPackageSource\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.EffectiveFetchPackageSource. Cleared after migration.\n\nScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"},"tpa_bundle_path":{"description":"TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json\nthe offline TPA scanner runs (spec 086 FR-019: the signature-DB location\nMUST be configuration-driven, not hardcoded). Empty (the default) runs the\ncorpus embedded in this build.\n\nEnv override: MCPPROXY_TPA_BUNDLE_PATH. Hot-reloadable — the path is\nre-read on every config.reloaded event via\nscanner.Service.ApplySecurityConfig, so a corpus refresh needs no restart.\nA configured bundle that fails to read/parse/version-check/compile is\nREFUSED and the previously active corpus stays live (fail-closed, never\nfail-empty); the reason is logged and surfaced in the security overview's\nsignature_bundle.load_error.","type":"string"}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"init_timeout":{"description":"InitTimeout overrides the global init_timeout for this server's MCP\n` + "`" + `initialize` + "`" + ` handshake deadline (MCP-3322 / GH #760). *Duration tri-state:\nnil = inherit the global value (or 30s default), positive = that deadline.\nResolved by Config.ResolveInitTimeout; validated to {0} ∪ [1s, 30m]. Raise\nthis for upstreams that do legitimate first-run warmup (e.g. caching many\nchannels/users) before responding to ` + "`" + `initialize` + "`" + `.","type":"string"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"launcher_wait_timeout":{"description":"LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched\nHTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted\nwhen the server is configured with both Command and an HTTP/SSE URL — i.e.,\nmcpproxy starts the process AND connects via network. Stdio servers ignore\nthis field. Zero or unset → 30s default.","type":"string"},"max_concurrent_requests":{"description":"Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).\nTri-state per setting, exactly like HealthCheckInterval: absent = inherit\nthe per-server default set (server_concurrency_defaults), explicit 0 =\ndisable that setting for this server (0 max = no per-server limiter at\nall; 0 queue_size = no pending capacity, shed immediately at the cap),\npositive = override. The global aggregate limiter is never inherited from\nhere — it applies on top, so effective concurrency is min(per-server,\nglobal). Resolved by Config.ResolveServerConcurrency.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/config.OAuthConfig"},"protocol":{"description":"stdio, http, sse, streamable-http, auto","type":"string"},"quarantined":{"description":"Security quarantine status","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets a disconnected server","type":"boolean"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"toon_output":{"description":"ToonOutput overrides the global toon_output mode for this server's\ntools (spec 084, FR-001). Plain string, not a pointer: \"\"/absent =\ninherit the global value; \"off\"|\"adaptive\"|\"always\" = override (\"off\"\nis the explicit force-off). Resolved by Config.ResolveToonOutput.","type":"string"},"trust_mode":{"description":"TrustMode is the per-server trust tier: auto|scan|manual. Supersedes\nauto_approve_tool_changes (spec 086). An empty value is derived from the\nlegacy fields at load via normalizeServerQuarantineFlags; the single\nresolution point is EffectiveTrustMode(), which treats an empty or\nunrecognized value as manual (secure by default). Read via\nEffectiveTrustMode(), never the raw string.","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TelemetryConfig":{"description":"Telemetry settings (Spec 036)","properties":{"anonymous_id":{"description":"Auto-generated UUIDv4","type":"string"},"anonymous_id_created_at":{"description":"Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"},"last_reported_version":{"description":"Upgrade funnel","type":"string"},"last_startup_outcome":{"description":"success|port_conflict|db_locked|...","type":"string"},"notice_shown":{"description":"First-run notice flag","type":"boolean"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"config.TracingExporterConfig":{"description":"Tracing gates the OpenTelemetry OTLP trace exporter (MCP-32). Disabled by\ndefault.","properties":{"enabled":{"description":"Enabled turns on OTLP trace export for tool calls and upstream hops.","type":"boolean"},"endpoint":{"description":"Endpoint is the collector address as host:port (no scheme), e.g.\n\"localhost:4318\" for http or \"localhost:4317\" for grpc.","type":"string"},"protocol":{"description":"Protocol selects the OTLP transport: \"http\" or \"grpc\".","type":"string"},"sample_rate":{"description":"SampleRate is the head-based trace sampling ratio in [0,1]. Default 0.1.","type":"number"}},"type":"object"},"config.UpdateCheckConfig":{"description":"Update-check settings (Spec 079 FR-012): config-file control of the\nbackground upgrade-awareness checker (internal/updatecheck). nil =\nenabled on the stable channel (existing default behavior). The existing\nenvironment switches keep working and WIN over these keys (FR-014):\nMCPPROXY_DISABLE_AUTO_UPDATE=true force-disables even when\nenabled=true, and MCPPROXY_ALLOW_PRERELEASE_UPDATES=true force-selects\nthe rc channel even when channel=stable.","properties":{"channel":{"description":"Channel selects which releases are offered as updates: \"stable\"\n(default; prereleases never offered) or \"rc\" (prereleases included).\nEmpty resolves to stable. Validated in ValidateDetailed.\n\nNOTE: for a RELEASED build the running binary's own version is\nauthoritative and overrides this field — a stable build is never\noffered an RC (even with channel=rc), and an RC build always tracks the\nrc channel. This field only takes effect on dev/unstamped builds. See\ninternal/updatecheck.Checker.IncludePrereleases.","type":"string"},"enabled":{"description":"Enabled gates all update checking. Tri-state: nil/absent = enabled\n(default true, matching pre-079 behavior). When false, no network\ncheck is performed and no upgrade nudge appears on any surface\n(FR-015) — /api/v1/info omits the update object entirely.","type":"boolean"}},"type":"object"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\"","type":"string"}},"type":"object"},"connect.ConnectResult":{"description":"The full result; its action mirrors the top-level one","properties":{"action":{"description":"\"created\", \"updated\", \"already_exists\", \"removed\", \"not_found\"","type":"string"},"backup_path":{"type":"string"},"client":{"type":"string"},"config_path":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"arguments":{"description":"Tool call arguments","type":"object"},"detection_types":{"description":"List of detection types found","items":{"type":"string"},"type":"array","uniqueItems":false},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP transport session ID (regenerated on every reconnect)","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\", \"rejected\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"},"work_session_id":{"description":"Spec 082: one client, one project, across reconnects","type":"string"}},"type":"object"},"contracts.ActivitySource":{"description":"How activity was triggered: \"mcp\", \"cli\", \"api\"","type":"string","x-enum-varnames":["ActivitySourceMCP","ActivitySourceCLI","ActivitySourceAPI"]},"contracts.ActivitySummaryResponse":{"properties":{"blocked_count":{"description":"Count of blocked activities","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"rejected_count":{"description":"RejectedCount is the number of calls shed by a concurrency limiter before\nthey reached an upstream (spec 093). Counted separately from errors: it is\nproxy backpressure, not an upstream fault, and it is the signal an\noperator right-sizes max_concurrent_requests against.","type":"integer"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"contracts.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeepScanDescriptor":{"description":"DeepScan reports the opt-in \"deep scan\" layer status (Spec 077 US3),\nSEPARATELY from the baseline verdict above. Always emitted on a computed\nsummary — when deep scan is off (the default) it reports enabled=false\nplus any enabled-but-skipped Docker scanners. It never influences Status.","properties":{"available":{"type":"boolean"},"enabled":{"type":"boolean"},"ran":{"type":"boolean"},"scanners_failed":{"items":{"$ref":"#/components/schemas/contracts.DeepScanScannerFailure"},"type":"array","uniqueItems":false},"skipped_scanners":{"description":"SkippedScanners lists Docker scanners the user enabled that are skipped\nbecause security.deep_scan.enabled is false (informational).","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DeepScanScannerFailure":{"properties":{"id":{"type":"string"},"reason":{"type":"string"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostic":{"description":"Spec 044 — structured diagnostic error and stable error code. Both\nare populated when the server is in a failed state and the error\nhas been classified by internal/diagnostics. Healthy servers omit\nthese fields.","properties":{"cause":{"type":"string"},"code":{"type":"string"},"detected_at":{"type":"string"},"docs_url":{"type":"string"},"fix_steps":{"items":{"$ref":"#/components/schemas/contracts.DiagnosticFixStep"},"type":"array","uniqueItems":false},"severity":{"type":"string"},"user_message":{"type":"string"}},"type":"object"},"contracts.DiagnosticFixStep":{"properties":{"command":{"type":"string"},"destructive":{"type":"boolean"},"fixer_key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.EditRegistrySourceRequest":{"properties":{"name":{"description":"new display name","type":"string"},"servers_url":{"description":"explicit servers-collection URL","type":"string"},"url":{"description":"new base/servers https URL","type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.FindingCounts":{"properties":{"dangerous":{"description":"Tool poisoning, active prompt injection","type":"integer"},"info":{"description":"Low-severity CVEs, informational","type":"integer"},"total":{"type":"integer"},"warning":{"description":"Rug pull, supply chain CVEs with exploits","type":"integer"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object","type":"object"},"config_path":{"description":"Path to config file","type":"string"}},"type":"object"},"contracts.GetRegistriesResponse":{"properties":{"registries":{"items":{"$ref":"#/components/schemas/contracts.Registry"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerLogsResponse":{"properties":{"count":{"type":"integer"},"logs":{"items":{"$ref":"#/components/schemas/contracts.LogEntry"},"type":"array","uniqueItems":false},"server_name":{"type":"string"}},"type":"object"},"contracts.GetServerToolCallsResponse":{"properties":{"server_name":{"type":"string"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerToolsResponse":{"properties":{"count":{"type":"integer"},"server_name":{"type":"string"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GetServersResponse":{"properties":{"servers":{"items":{"$ref":"#/components/schemas/contracts.Server"},"type":"array","uniqueItems":false},"stats":{"$ref":"#/components/schemas/contracts.ServerStats"}},"type":"object"},"contracts.GetSessionDetailResponse":{"properties":{"session":{"$ref":"#/components/schemas/contracts.MCPSession"}},"type":"object"},"contracts.GetSessionsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"sessions":{"items":{"$ref":"#/components/schemas/contracts.MCPSession"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetToolCallDetailResponse":{"properties":{"tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"}},"type":"object"},"contracts.GetToolCallsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GlobalToolsResponse":{"properties":{"failed_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"partial":{"type":"boolean"},"stats":{"$ref":"#/components/schemas/contracts.GlobalToolsStats"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GlobalToolsStats":{"properties":{"disabled":{"type":"integer"},"enabled":{"type":"integer"},"pending_approval":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.HealthStatus":{"description":"Unified health status calculated by the backend","properties":{"action":{"description":"Action is the suggested fix action: \"login\", \"restart\", \"enable\", \"approve\", \"view_logs\", \"set_secret\", \"configure\", or \"\" (none)","type":"string"},"admin_state":{"description":"AdminState indicates the admin state: \"enabled\", \"disabled\", or \"quarantined\"","type":"string"},"detail":{"description":"Detail is an optional longer explanation of the status","type":"string"},"level":{"description":"Level indicates the health level: \"healthy\", \"degraded\", or \"unhealthy\"","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"}},"type":"object"},"contracts.InfoEndpoints":{"description":"Available API endpoints","properties":{"http":{"description":"HTTP endpoint address (e.g., \"127.0.0.1:8080\")","type":"string"},"socket":{"description":"Unix socket path (empty if disabled)","type":"string"}},"type":"object"},"contracts.InfoResponse":{"properties":{"endpoints":{"$ref":"#/components/schemas/contracts.InfoEndpoints"},"launched_by":{"description":"LaunchedBy is the durable launch provenance of the running core (Spec\n092 FR-001a): \"tray\" when a tray spawned it, \"installer\" when the macOS\nPKG postinstall did, \"\" when user-launched or unknown. Always present\n(possibly empty) so a tray can distinguish \"old core, not mine\" from\n\"old core I may supersede\".","type":"string"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"pid":{"description":"PID is the operating-system process id of the running core (Spec 092\nFR-002). A tray that merely ATTACHED to a core holds no Process handle\nfor it, so without this there is no mechanism at all to stop a stale\ncore — the consent action would have nothing to act on and could only\nprint instructions. Paired with LaunchedBy it is what lets a newer tray\nsupersede a core an older tray started.","type":"integer"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"update_policy":{"$ref":"#/components/schemas/contracts.UpdatePolicy"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"network_mode":{"type":"string"},"timeout":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationDefaults":{"description":"IsolationDefaults exposes the resolved baseline values that\nwould apply when no per-server override is set. Populated on\nlist/get responses; never consumed on PATCH requests.","properties":{"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"runtime_type":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.LogEntry":{"properties":{"fields":{"type":"object"},"level":{"type":"string"},"message":{"type":"string"},"server":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.MCPSession":{"properties":{"client_name":{"type":"string"},"client_version":{"type":"string"},"end_time":{"type":"string"},"experimental":{"items":{"type":"string"},"type":"array","uniqueItems":false},"has_roots":{"description":"MCP Client Capabilities","type":"boolean"},"has_sampling":{"type":"boolean"},"id":{"type":"string"},"last_activity":{"type":"string"},"start_time":{"type":"string"},"status":{"type":"string"},"tool_call_count":{"type":"integer"},"total_tokens":{"type":"integer"},"work_session_id":{"type":"string"},"workspace_name":{"description":"Workspace / work session (Spec 082). WorkspaceName is the project's\nbasename — the full local path is never exposed. WorkSessionID groups the\nreconnects that make up one stretch of user work.","type":"string"}},"type":"object"},"contracts.MetadataStatus":{"properties":{"authorization_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"error":{"type":"string"},"found":{"type":"boolean"},"url_checked":{"type":"string"}},"type":"object"},"contracts.MissingSecretInfo":{"properties":{"secret_name":{"type":"string"},"used_by":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.NPMPackageInfo":{"properties":{"exists":{"type":"boolean"},"install_cmd":{"type":"string"}},"type":"object"},"contracts.OAuthConfig":{"properties":{"auth_url":{"type":"string"},"client_id":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_port":{"type":"integer"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_expires_at":{"description":"When the OAuth token expires","type":"string"},"token_url":{"type":"string"},"token_valid":{"description":"Whether token is currently valid","type":"boolean"}},"type":"object"},"contracts.OAuthErrorDetails":{"description":"Structured discovery/failure details","properties":{"authorization_server_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"dcr_status":{"$ref":"#/components/schemas/contracts.DCRStatus"},"protected_resource_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"server_url":{"type":"string"}},"type":"object"},"contracts.OAuthFlowError":{"properties":{"correlation_id":{"description":"Flow tracking ID for log correlation","type":"string"},"debug_hint":{"description":"CLI command for log lookup","type":"string"},"details":{"$ref":"#/components/schemas/contracts.OAuthErrorDetails"},"error_code":{"description":"Machine-readable error code (e.g., OAUTH_NO_METADATA)","type":"string"},"error_type":{"description":"Category of OAuth runtime failure","type":"string"},"message":{"description":"Human-readable error description","type":"string"},"request_id":{"description":"HTTP request ID (from PR #237)","type":"string"},"server_name":{"description":"Server that failed OAuth","type":"string"},"success":{"description":"Always false","type":"boolean"},"suggestion":{"description":"Actionable remediation hint","type":"string"}},"type":"object"},"contracts.OAuthIssue":{"properties":{"documentation_url":{"type":"string"},"error":{"type":"string"},"issue":{"type":"string"},"missing_params":{"items":{"type":"string"},"type":"array","uniqueItems":false},"resolution":{"type":"string"},"server_name":{"type":"string"}},"type":"object"},"contracts.OAuthRequirement":{"properties":{"expires_at":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"state":{"type":"string"}},"type":"object"},"contracts.OAuthStartResponse":{"properties":{"auth_url":{"description":"Authorization URL (always included for manual use)","type":"string"},"browser_error":{"description":"Error message if browser launch failed","type":"string"},"browser_opened":{"description":"Whether browser launch succeeded","type":"boolean"},"correlation_id":{"description":"UUID for tracking this flow","type":"string"},"message":{"description":"Human-readable status message","type":"string"},"server_name":{"description":"Name of the server being authenticated","type":"string"},"success":{"description":"Always true for successful start","type":"boolean"}},"type":"object"},"contracts.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"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"init_timeout":{"description":"InitTimeout mirrors config.ServerConfig.InitTimeout (MCP-3322 / GH #760):\nthe per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override. Serialized as\na duration string (e.g. \"120s\"); nil/omitted means \"inherit the global\ndefault\". Surfaced on the GET path so clients can read back a configured\noverride; PATCH/POST accept it via AddServerRequest.","type":"string"},"isolation":{"$ref":"#/components/schemas/contracts.IsolationConfig"},"isolation_defaults":{"$ref":"#/components/schemas/contracts.IsolationDefaults"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"max_concurrent_requests":{"description":"Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of\nFR-020. Each setting is tri-state: nil (omitted) means \"inherit\nserver_concurrency_defaults\", 0 disables that setting for this server,\npositive overrides it. Surfaced on the GET path so a caller can read back\nwhat it set; PATCH/POST accept them via AddServerRequest. The effective\nconcurrency for a server is additionally bounded by the global aggregate\nlimiter, which is NOT an inheritance source for these fields.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"security_scan":{"$ref":"#/components/schemas/contracts.SecurityScanSummary"},"should_retry":{"type":"boolean"},"source_registry_id":{"description":"MCP-901 — registry provenance of an upstream that was added from a\nregistry. SourceRegistryID names the source registry (empty for\nmanually-configured servers); SourceRegistryProvenance is the trust tag\nrecorded at add time (\"official/trusted\" or \"custom/unverified\"). Both\nare projected from config.ServerConfig so the approval/quarantine view\ncan render an \"added from \u003cregistry\u003e · unverified\" origin badge. Optional\nand omitted when empty — clients that pre-date this treat them as absent.","type":"string"},"source_registry_provenance":{"type":"string"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"trust_mode":{"description":"TrustMode mirrors config.ServerConfig.TrustMode (spec 086): the per-server\ntrust tier (\"auto\"/\"scan\"/\"manual\"). Surfaced on the GET path so clients can\nread back the persisted mode; PATCH/POST accept it via AddServerRequest.\nOmitted when empty (server predates the field / relies on legacy flags).","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"user_logged_out":{"description":"True if user explicitly logged out (prevents auto-reconnection)","type":"boolean"},"working_dir":{"type":"string"}},"type":"object"},"contracts.ServerActionResponse":{"properties":{"action":{"type":"string"},"async":{"type":"boolean"},"server":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ServerStats":{"properties":{"connected_servers":{"type":"integer"},"docker_containers":{"type":"integer"},"quarantined_servers":{"type":"integer"},"token_metrics":{"$ref":"#/components/schemas/contracts.ServerTokenMetrics"},"total_servers":{"type":"integer"},"total_tools":{"type":"integer"}},"type":"object"},"contracts.ServerTokenMetrics":{"properties":{"average_query_result_size":{"description":"Typical retrieve_tools output (tokens)","type":"integer"},"per_server_tool_list_sizes":{"additionalProperties":{"type":"integer"},"description":"Token size per server","type":"object"},"saved_tokens":{"description":"Difference","type":"integer"},"saved_tokens_percentage":{"description":"Percentage saved","type":"number"},"total_server_tool_list_size":{"description":"All upstream tools combined (tokens)","type":"integer"}},"type":"object"},"contracts.SuccessResponse":{"properties":{"data":{"type":"object"},"success":{"type":"boolean"}},"type":"object"},"contracts.TokenMetrics":{"description":"Token usage metrics (nil for older records)","properties":{"encoding":{"description":"Encoding used (e.g., cl100k_base)","type":"string"},"estimated_cost":{"description":"Optional cost estimate","type":"number"},"input_tokens":{"description":"Tokens in the request","type":"integer"},"model":{"description":"Model used for tokenization","type":"string"},"output_tokens":{"description":"Tokens in the response","type":"integer"},"total_tokens":{"description":"Total tokens (input + output)","type":"integer"},"truncated_tokens":{"description":"Tokens removed by truncation","type":"integer"},"was_truncated":{"description":"Whether response was truncated","type":"boolean"}},"type":"object"},"contracts.Tool":{"properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"approval_status":{"type":"string"},"config_denied":{"description":"ConfigDenied is true when the tool is denied by the server's static\nenabled_tools / disabled_tools config. The user cannot override this toggle.","type":"boolean"},"description":{"type":"string"},"disabled":{"description":"Disabled mirrors ToolApprovalRecord.Disabled so per-tool enable state is\navailable without a second round-trip to the approvals endpoint. Absent\nin the JSON when false (default) to keep responses compact.","type":"boolean"},"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"},"config_path":{"description":"Active config file path","type":"string"},"duration":{"description":"Duration in nanoseconds","type":"integer"},"error":{"description":"Error message (failure only)","type":"string"},"execution_type":{"description":"\"direct\" or \"code_execution\"","type":"string"},"id":{"description":"Unique identifier","type":"string"},"mcp_client_name":{"description":"MCP client name from InitializeRequest","type":"string"},"mcp_client_version":{"description":"MCP client version","type":"string"},"mcp_session_id":{"description":"MCP session identifier","type":"string"},"metrics":{"$ref":"#/components/schemas/contracts.TokenMetrics"},"parent_call_id":{"description":"Links nested calls to parent code_execution","type":"string"},"request_id":{"description":"Request correlation ID","type":"string"},"response":{"description":"Tool response (success only)","type":"object"},"server_id":{"description":"Server identity hash","type":"string"},"server_name":{"description":"Human-readable server name","type":"string"},"timestamp":{"description":"When the call was made","type":"string"},"tool_name":{"description":"Tool name (without server prefix)","type":"string"}},"type":"object"},"contracts.UpdateInfo":{"description":"Update information (if available)","properties":{"available":{"description":"Whether an update is available","type":"boolean"},"check_error":{"description":"Error message if update check failed","type":"string"},"checked_at":{"description":"When the update check was performed","type":"string"},"install_channel":{"description":"Detected install channel (homebrew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown) — Spec 079 FR-008","type":"string"},"is_prerelease":{"description":"Whether the latest version is a prerelease","type":"boolean"},"latest_version":{"description":"Latest version available (e.g., \"v1.2.3\")","type":"string"},"nudges_suppressed":{"description":"UI surfaces must stay quiet (CI / non-interactive context); machine-readable fields still report the facts — Spec 079 FR-019","type":"boolean"},"release_url":{"description":"URL to the release page","type":"string"},"update_command":{"description":"One-line update command for the channel; only set when an update is available and the channel has one — Spec 079 FR-009","type":"string"}},"type":"object"},"contracts.UpdatePolicy":{"description":"UpdatePolicy is the effective, hot-reloadable update policy (Spec 092\nFR-015). Always present: the ` + "`" + `update` + "`" + ` object above is omitted both when\nupdate checking is disabled AND when no check has produced a result\nyet, so its absence cannot tell a client whether it is allowed to run\nits own (e.g. Sparkle feed) check. This field states the answer.","properties":{"channel":{"description":"Channel is the tracked release channel: \"stable\" or \"rc\".","type":"string"},"enabled":{"description":"Enabled is the effective automatic-check kill switch: update_check.enabled\nwith MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated\n\"Check for Updates\" stays available regardless.","type":"boolean"},"nudges_suppressed":{"description":"NudgesSuppressed asks UI surfaces to stay quiet (CI / non-interactive)\nwhile machine-readable fields keep reporting the facts.","type":"boolean"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.UsageAggregateResponse":{"properties":{"freshness_ms":{"description":"age of the underlying snapshot in ms","type":"integer"},"generated_at":{"type":"string"},"other":{"$ref":"#/components/schemas/contracts.UsageOtherBucket"},"timeline":{"items":{"$ref":"#/components/schemas/contracts.UsageTimeBucket"},"type":"array","uniqueItems":false},"token_source":{"description":"\"bytes\" (size-based proxy, FR-006)","type":"string"},"tokens_saved":{"description":"echoed from ServerTokenMetrics (FR-007)","type":"integer"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"window":{"type":"string"}},"type":"object"},"contracts.UsageOtherBucket":{"description":"present only when the list was truncated to top-N","properties":{"calls":{"type":"integer"},"tools_folded":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageTimeBucket":{"properties":{"calls":{"type":"integer"},"errors":{"type":"integer"},"start":{"type":"string"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageToolStat":{"properties":{"avg_req_bytes":{"description":"null when no sized request calls","type":"integer"},"avg_resp_bytes":{"description":"null when sized_calls == 0 (only legacy 0-byte calls)","type":"integer"},"blocked":{"type":"integer"},"calls":{"type":"integer"},"error_rate":{"type":"number"},"errors":{"type":"integer"},"last_used":{"type":"string"},"p50_ms":{"type":"integer"},"p95_ms":{"type":"integer"},"rejected":{"description":"spec 093: shed by a concurrency limit; never executed, so excluded from calls/latency","type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"init_timeout":{"description":"InitTimeout is the per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override\n(MCP-3322 / GH #760), serialized as a duration string (e.g. \"120s\"). A nil\npointer means \"leave unchanged\" on PATCH; a present value is applied.\nMirrors config.ServerConfig.InitTimeout's *Duration tri-state.","type":"string"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"max_concurrent_requests":{"description":"MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server\nconcurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is\ntri-state: a nil pointer means \"leave unchanged\" on PATCH and \"inherit\nserver_concurrency_defaults\" on create; an explicit 0 disables that\nsetting for this server; a positive value overrides it. Do NOT collapse\nthem to plain values — an omitted field would then silently reset a\nconfigured limit.","type":"integer"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"type":"boolean"},"trust_mode":{"description":"TrustMode is the per-server trust tier (spec 086): \"auto\", \"scan\", or\n\"manual\". Empty means \"leave unchanged\" on PATCH (and inherit the migrated\ndefault on create). A non-empty value is applied to ServerConfig.TrustMode\nand resolved by EffectiveTrustMode (an unrecognized value fails closed to\nmanual). This is the REST seam for changing the trust tier via\nPOST/PATCH /api/v1/servers.","type":"string"},"url":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.CanonicalConfigPath":{"properties":{"description":{"description":"Brief description","type":"string"},"exists":{"description":"Whether the file exists","type":"boolean"},"format":{"description":"Format identifier (e.g., \"claude_desktop\")","type":"string"},"name":{"description":"Display name (e.g., \"Claude Desktop\")","type":"string"},"os":{"description":"Operating system (darwin, windows, linux)","type":"string"},"path":{"description":"Full path to the config file","type":"string"}},"type":"object"},"httpapi.CanonicalConfigPathsResponse":{"properties":{"os":{"description":"Current operating system","type":"string"},"paths":{"description":"List of canonical config paths","items":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPath"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ConnectConflictResponse":{"properties":{"action":{"description":"already_exists | precondition_failed","type":"string"},"data":{"$ref":"#/components/schemas/connect.ConnectResult"},"error":{"description":"Human-readable message","type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"precondition_token":{"description":"PreconditionToken is the opaque token from the preview this write was\nconfirmed against (Spec 091 FR-005). When present, the core rechecks it\nat write time and responds 409 with action \"precondition_failed\" —\nwriting nothing — if the config or the entry MCPProxy would write has\ndrifted since; the caller then re-previews instead of retrying. Absent\nmeans exactly the pre-091 behavior. A replace-classified flow sends this\nTOGETHER with force=true: the token, not the absence of force, is the\noverwrite safety.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.ImportFromPathRequest":{"properties":{"format":{"description":"Optional format hint","type":"string"},"path":{"description":"File path to import from","type":"string"},"rename":{"additionalProperties":{"type":"string"},"description":"Rename maps a server name → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys are matched against\neither the raw source name (OriginalName) or the sanitized name shown\nin the preview (Server.Name); these differ for names that need\nsanitizing (e.g. \"Figma Desktop\" → \"Figma_Desktop\"). Keys not present\nin the imported set are ignored.","type":"object"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportRequest":{"properties":{"content":{"description":"Raw JSON or TOML content","type":"string"},"format":{"description":"Optional format hint","type":"string"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportResponse":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/configimport.FailedServer"},"type":"array","uniqueItems":false},"format":{"type":"string"},"format_name":{"type":"string"},"imported":{"items":{"$ref":"#/components/schemas/httpapi.ImportedServerResponse"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/configimport.SkippedServer"},"type":"array","uniqueItems":false},"summary":{"$ref":"#/components/schemas/configimport.ImportSummary"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportedServerResponse":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (image,\nnetwork_mode, extra_args, working_dir, enabled). A nil pointer\nmeans \"do not touch isolation config\"; an empty-but-present\nobject on PATCH intentionally clears the overrides.","properties":{"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.OnboardingMarkRequest":{"properties":{"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value. The stored enum is wider (Spec 080\nFR-001): a \"skipped\" request for a previously untouched connect step\nis upgraded server-side to \"completed_external\" when the install\nshows positive evidence of an external connection (Spec 080 FR-002).\n\"completed_external\" is NOT accepted from clients — it must never be\npersisted without that server-verified evidence (edge case: \"never\nguess completed_external without positive evidence\").","type":"string"},"engaged":{"description":"Engaged marks the wizard as engaged (completed or explicitly skipped).\nOnce true, the wizard does not auto-show again.","type":"boolean"},"mark_shown":{"description":"MarkShown records the wizard's first display time if not already set.","type":"boolean"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","type":"string"}},"type":"object"},"httpapi.SetActiveProfileRequest":{"properties":{"active_profile":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.UndoConnectRequest":{"properties":{"backup_name":{"description":"BackupName is the bare filename (filepath.Base) of the backup returned as\nbackup_path by the preceding connect — a name, never a path. Undo resolves\nthe full path server-side by joining it with the client's own config\ndirectory, so a client-supplied value can never contribute a directory\ncomponent (traversal is impossible by construction). Empty means the\nconnect created the file (no prior file existed), so undo removes it.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.UpdateFailureRequest":{"properties":{"stage":{"description":"Stage is the failure stage of the update session.","enum":["appcast","download","install","other"],"type":"string"}},"type":"object"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned upstream servers (MCP-2769). It is OFF by\ndefault and deliberately kept out of the AllowedSystemVars default list:\nproxy URLs frequently carry credentials (http://user:pass@proxy), so\nforwarding them to every stdio upstream is a credential-leak risk. When\nenabled, values are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"},"telemetry.FeedbackContext":{"properties":{"arch":{"type":"string"},"connected_server_count":{"type":"integer"},"edition":{"type":"string"},"os":{"type":"string"},"routing_mode":{"type":"string"},"server_count":{"type":"integer"},"version":{"type":"string"}},"type":"object"},"telemetry.FeedbackRequest":{"properties":{"category":{"description":"bug, feature, other","type":"string"},"context":{"$ref":"#/components/schemas/telemetry.FeedbackContext"},"email":{"type":"string"},"message":{"type":"string"}},"type":"object"},"telemetry.FeedbackResponse":{"properties":{"error":{"type":"string"},"issue_url":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"description":"API key authentication via query parameter. Use ?apikey=your-key","in":"query","name":"apikey","type":"apiKey"}}}, "info": {"contact":{"name":"MCPProxy Support","url":"https://github.com/smart-mcp-proxy/mcpproxy-go"},"description":"{{escape .Description}}","license":{"name":"MIT","url":"https://opensource.org/licenses/MIT"},"title":"{{.Title}}","version":"{{.Version}}"}, "externalDocs": {"description":"","url":""}, - "paths": {"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Omit arguments, response and metadata except a contextual whitelist (intent.reason, intent.operation_type, decision, reason, client_name, client_version) (default: false). For clients that render summary fields only; has_sensitive_data is still derived before metadata is dropped.","in":"query","name":"exclude_payloads","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to export (1-50000, default 10000)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get usage statistics aggregate","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/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"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Apply configuration","tags":["config"]}},"/api/v1/config/docker-isolation":{"patch":{"description":"Convenience endpoint to flip ` + "`" + `docker_isolation.enabled` + "`" + ` without resending the full config. Persists to disk via the existing config writer — the file watcher then hot-reloads the change. Returns the new state and whether a restart is required for existing connections to pick it up.","requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"}},"type":"object"}}},"description":"New isolation state","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Isolation toggle applied"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Toggle global Docker isolation","tags":["config"]}},"/api/v1/config/validate":{"post":{"description":"Validates a provided MCPProxy configuration without applying it. Checks for syntax errors, invalid server definitions, conflicting settings, and other configuration issues.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to validate","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ValidateConfigResponse"}}},"description":"Configuration validation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Validation failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Validate configuration","tags":["config"]}},"/api/v1/connect":{"get":{"description":"Returns the connection status for all known MCP client applications.\nEach entry indicates whether the client config file exists and whether\nMCPProxy is currently registered in it.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"List of ClientStatus objects"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List client connection status","tags":["connect"]}},"/api/v1/connect/{client}":{"delete":{"description":"Remove the MCPProxy entry from the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional parameters (server_name)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or entry not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disconnect MCPProxy from a client","tags":["connect"]},"get":{"description":"Resolves one client's status by reading its config file on demand.\nThis is the only Connect endpoint that opens a client config file, so\non macOS it is the sole place an App-Data privacy prompt may legitimately\nappear (scoped to this user action). Resolves access_state to\naccessible|absent|denied|malformed and populates remediation when denied.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get a single client's connection status (on-demand)","tags":["connect"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.\nOptionally accepts precondition_token from a preview (Spec 091): when supplied,\nthe core rechecks the raw pre-write state and the entry it would write, and\nrefuses a drifted write with 409 before taking any backup. The 409 body's\naction discriminates the two conflict kinds: \"precondition_failed\" (stale\npreview — re-preview, do not retry) vs \"already_exists\" (entry present — pass\nforce=true). force=true never rescues a stale token.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters (server_name, force, precondition_token)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectConflictResponse"}}},"description":"Conflict: action=already_exists (use force=true) or action=precondition_failed (preview is stale; re-preview)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Connect MCPProxy to a client","tags":["connect"]}},"/api/v1/connect/{client}/preview":{"get":{"description":"Returns the exact entry a subsequent connect would add to the client's\nconfig — target path, server key, entry name, and entry contents — WITHOUT\nmodifying the file or creating a backup (Spec 078 US1). The embedded API key\nis masked in the payload; contains_api_key flags that a credential is written.\nentry_exists distinguishes a create from an overwrite of a same-named entry.\nReads the config on demand to classify create-vs-overwrite, so on macOS this\nmay raise an App-Data privacy prompt; a denial returns 403 + remediation.\nSpec 091 adds three fields: existing_entry_summary (present only when\nentry_exists — a sanitized, non-secret projection of the entry being replaced:\nits name, type, endpoint with query/userinfo stripped, command, and header and\nenv NAMES, never values); precondition_token (always present — an opaque keyed\ndigest of the raw pre-write state and the pending entry, echoed back on POST\nconnect to detect drift); and connect_refusal (present when the write would\nrefuse regardless of intent, e.g. a non-create-capable client with no config —\ntreat its presence as \"Connect unavailable\").","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Entry name to preview (defaults to mcpproxy); mirror the value passed to POST connect","in":"query","name":"server_name","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectPreview"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview the change a connect would make (no write)","tags":["connect"]}},"/api/v1/connect/{client}/undo":{"post":{"description":"Reverts the connect that produced the named backup (Spec 078 US3):\nrestores the client config byte-for-byte from that backup, or — when\nbackup_name is empty because the connect created the file — deletes the\ncreated file. backup_name is the bare filename of the backup the connect\nreturned (never a path); undo resolves the full path server-side inside\nthe client's own config directory, so a client value cannot escape it.\nRefuses with 409 when the config changed since the connect (undo never\nclobbers later edits; use DELETE /connect/{client} for a surgical entry\nremoval instead). Takes its own safety backup first; its path is returned\nas backup_path in the result.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UndoConnectRequest"}}},"description":"Undo parameters (server_name, backup_name = the bare filename of the backup the preceding connect returned)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult (action restored|deleted)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (e.g. backup_name is a path, or not a backup of this client's config)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or backup no longer exists"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Config changed since connect; undo refused"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Undo a connect, restoring the pre-connect config","tags":["connect"]}},"/api/v1/diagnostics":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/docker/status":{"get":{"description":"Retrieve current Docker availability and recovery status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Docker status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get Docker status","tags":["docker"]}},"/api/v1/doctor":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/feedback":{"post":{"description":"Submit a bug report, feature request, or general feedback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackRequest"}}},"description":"Feedback request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Bad Request"},"429":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyAuth":[]}],"summary":"Submit feedback","tags":["feedback"]}},"/api/v1/index/search":{"get":{"description":"Search across all upstream MCP server tools using BM25 keyword search","parameters":[{"description":"Search query","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Maximum number of results","in":"query","name":"limit","schema":{"default":10,"maximum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchToolsResponse"}}},"description":"Search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing query parameter)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search for tools","tags":["tools"]}},"/api/v1/info":{"get":{"description":"Get essential server metadata including version, web UI URL, endpoint addresses, and update availability\nThis endpoint is designed for tray-core communication and version checking\nUse refresh=true query parameter to force an immediate update check against GitHub\nThe launched_by field reports durable launch provenance (\"tray\", \"installer\", or \"\" for user-launched/unknown)","parameters":[{"description":"Force immediate update check against GitHub","in":"query","name":"refresh","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Server information with optional update info"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server information","tags":["status"]}},"/api/v1/onboarding/mark":{"post":{"description":"Updates wizard engagement and per-step status. Once engaged is\ntrue, the wizard does not auto-show again, even if state regresses.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.OnboardingMarkRequest"}}},"description":"Mark request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Mark onboarding wizard state (Spec 046)","tags":["onboarding"]}},"/api/v1/onboarding/state":{"get":{"description":"Returns the wizard engagement record alongside live predicates\n(whether any client is connected, whether any server is configured),\nplus a derived ShouldShowWizard flag the frontend can rely on.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"OnboardingStateResponse"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get onboarding wizard state and predicates (Spec 046)","tags":["onboarding"]}},"/api/v1/profiles":{"get":{"description":"List all configured profiles with their effective servers and indexed tool count (Profiles v2). A profile scopes tool discovery and calls to a named subset of upstream servers.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Profile list"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Configuration unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List configured profiles","tags":["profiles"]}},"/api/v1/profiles/active":{"get":{"description":"Get the server-level default active profile used by UI surfaces (Web UI / tray). Empty string means \"all servers\". Note: within a live MCP session, the set_profile tool selection takes precedence over this default.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the default active profile","tags":["profiles"]},"put":{"description":"Set the server-level default active profile for UI surfaces. The slug must match a configured profile; pass an empty string to clear. This does not affect live MCP sessions, which use the set_profile tool.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.SetActiveProfileRequest"}}},"description":"Profile slug to activate (empty clears)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request body"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Set the default active profile","tags":["profiles"]}},"/api/v1/registries":{"get":{"description":"Retrieves list of all MCP server registries that can be browsed for discovering and installing new upstream servers. Includes registry metadata, server counts, and API endpoints.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetRegistriesResponse"}}},"description":"Registries retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to list registries"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List available MCP server registries","tags":["registries"]},"post":{"description":"Adds a generic modelcontextprotocol/registry v0.1 https endpoint as a custom registry (MCP-866). The source is always tagged custom/unverified, so every server discovered through it lands quarantined and can never skip quarantine.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddRegistrySourceRequest"}}},"description":"Registry source (https url + optional protocol/id/name)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source added"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/contracts.ErrorResponse"},{"$ref":"#/components/schemas/contracts.ErrorResponse"}]}}},"description":"Forbidden (agent tokens cannot mutate registries)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin | duplicate_registry"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a user-supplied registry source","tags":["registries"]}},"/api/v1/registries/{id}":{"delete":{"description":"Removes a custom/unverified registry previously added via add-source (MCP-1057). Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source removed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove a user-added custom registry source","tags":["registries"]},"put":{"description":"Updates a custom registry previously added via add-source (MCP-1072): name, url, servers-url. Empty fields are left unchanged. Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found; a non-https url yields invalid_registry_url. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.EditRegistrySourceRequest"}}},"description":"Fields to update (name/url/servers_url; empty = unchanged)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required | invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Edit a user-added custom registry source","tags":["registries"]}},"/api/v1/registries/{id}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot add servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove an upstream server","tags":["servers"]},"patch":{"description":"Update specific fields of an existing upstream MCP server configuration.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Fields to update (all optional)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server updated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - no fields or invalid body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/config-to-secret":{"post":{"description":"Atomically reads the real value from the server config, stores it in the OS keyring, and rewrites the config field to ` + "`" + `${keyring:\u003cname\u003e}` + "`" + `. Unblocks the UI's Convert-to-secret affordance for values the API redacts on the read path.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored, config updated with reference"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad scope/key/secret_name, or value is already a reference / empty"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server or key not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver or config update failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Convert a header / env value to a keyring secret","tags":["servers"]}},"/api/v1/servers/{id}/disable":{"post":{"description":"Disable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server disabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/discover-tools":{"post":{"description":"Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool discovery triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot discover tools)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to discover tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Discover tools for a specific server","tags":["servers"]}},"/api/v1/servers/{id}/enable":{"post":{"description":"Enable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server enabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/login":{"post":{"description":"Initiate OAuth authentication flow for a specific upstream MCP server. Returns structured OAuth start response with correlation ID for tracking.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthStartResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthFlowError"}}},"description":"OAuth error (client_id required, DCR failed, etc.)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Trigger OAuth login for server","tags":["servers"]}},"/api/v1/servers/{id}/logout":{"post":{"description":"Clear OAuth authentication token and disconnect a specific upstream MCP server. The server will need to re-authenticate before tools can be used again.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"OAuth logout completed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled or read-only mode)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Clear OAuth token and disconnect server","tags":["servers"]}},"/api/v1/servers/{id}/logs":{"get":{"description":"Retrieve log entries for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Number of log lines to retrieve","in":"query","name":"tail","schema":{"default":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerLogsResponse"}}},"description":"Server logs retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server logs","tags":["servers"]}},"/api/v1/servers/{id}/quarantine":{"post":{"description":"Place a specific upstream MCP server in quarantine to prevent tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server quarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Quarantine a server","tags":["servers"]}},"/api/v1/servers/{id}/refresh":{"post":{"description":"Re-discover and re-index a specific upstream MCP server's tools without changing any security state. Alias of discover-tools, named for the upstream_servers 'refresh' operation; use it to make just-approved tools searchable immediately.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool refresh triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot refresh)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Refresh a server's tools","tags":["servers"]}},"/api/v1/servers/{id}/restart":{"post":{"description":"Restart the connection to a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server restarted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/tool-calls":{"get":{"description":"Retrieves tool call history filtered by upstream server ID. Returns recent tool executions for the specified server including timestamps, arguments, results, and errors. Useful for server-specific debugging and monitoring.","parameters":[{"description":"Upstream server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolCallsResponse"}}},"description":"Server tool calls retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get server tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history for specific server","tags":["tool-calls"]}},"/api/v1/servers/{id}/tools":{"get":{"description":"Retrieve all available tools for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolsResponse"}}},"description":"Server tools retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/block":{"post":{"description":"Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The \"blocked\" field counts tools actually blocked.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Block result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Block (approve+disable) tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/disable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/enable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/unquarantine":{"post":{"description":"Remove a specific upstream MCP server from quarantine to allow tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server unquarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Unquarantine a server","tags":["servers"]}},"/api/v1/sessions":{"get":{"description":"Retrieves paginated list of active and recent MCP client sessions. Each session represents a connection from an MCP client to MCPProxy, tracking initialization time, tool calls, and connection status.","parameters":[{"description":"Maximum number of sessions to return (1-100, default 10)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of sessions to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter by session status","in":"query","name":"status","schema":{"enum":["active","closed"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid status filter"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get sessions"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get active MCP sessions","tags":["sessions"]}},"/api/v1/sessions/{id}":{"get":{"description":"Retrieves detailed information about a specific MCP client session including initialization parameters, connection status, tool call count, and activity timestamps.","parameters":[{"description":"Session ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionDetailResponse"}}},"description":"Session details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get MCP session details by ID","tags":["sessions"]}},"/api/v1/stats/tokens":{"get":{"description":"Retrieve token savings statistics across all servers and sessions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Token statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get token savings statistics","tags":["stats"]}},"/api/v1/status":{"get":{"description":"Get comprehensive server status including running state, listen address, upstream statistics, and timestamp","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server status","tags":["status"]}},"/api/v1/telemetry/payload":{"get":{"description":"Render the exact JSON heartbeat payload that mcpproxy would next send to the telemetry endpoint, without making a network call. Counters in the payload reflect the current in-memory state. Spec 042.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Telemetry heartbeat payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/telemetry/update-failure":{"post":{"description":"Records one terminal update-session failure, identified only by its\nstage (appcast, download, install, other). The body carries no error\ntext, URL, or version — the stage is the only value transmitted.\nReturns 204 both when the occurrence was durably persisted and when\ntelemetry is inactive at event time (config opt-out, environment\nopt-out, CI, or dev build), in which case nothing is recorded.\nCallers cannot and need not distinguish the two.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UpdateFailureRequest"}}},"description":"Update failure stage","required":true},"responses":{"204":{"description":"Accepted (recorded, or a deliberate no-op while telemetry is inactive)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Malformed body, unknown field, trailing value, or stage outside the closed set"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Persistence failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Record a desktop auto-update failure occurrence (Spec 095)","tags":["telemetry"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}}, + "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"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Omit arguments, response and metadata except a contextual whitelist (intent.reason, intent.operation_type, decision, reason, client_name, client_version) (default: false). For clients that render summary fields only; has_sensitive_data is still derived before metadata is dropped.","in":"query","name":"exclude_payloads","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to export (1-50000, default 10000)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get usage statistics aggregate","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/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"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Apply configuration","tags":["config"]}},"/api/v1/config/docker-isolation":{"patch":{"description":"Convenience endpoint to flip ` + "`" + `docker_isolation.enabled` + "`" + ` without resending the full config. Persists to disk via the existing config writer — the file watcher then hot-reloads the change. Returns the new state and whether a restart is required for existing connections to pick it up.","requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"}},"type":"object"}}},"description":"New isolation state","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Isolation toggle applied"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Toggle global Docker isolation","tags":["config"]}},"/api/v1/config/validate":{"post":{"description":"Validates a provided MCPProxy configuration without applying it. Checks for syntax errors, invalid server definitions, conflicting settings, and other configuration issues.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to validate","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ValidateConfigResponse"}}},"description":"Configuration validation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Validation failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Validate configuration","tags":["config"]}},"/api/v1/connect":{"get":{"description":"Returns the connection status for all known MCP client applications.\nEach entry indicates whether the client config file exists and whether\nMCPProxy is currently registered in it.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"List of ClientStatus objects"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List client connection status","tags":["connect"]}},"/api/v1/connect/{client}":{"delete":{"description":"Remove the MCPProxy entry from the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional parameters (server_name)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or entry not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disconnect MCPProxy from a client","tags":["connect"]},"get":{"description":"Resolves one client's status by reading its config file on demand.\nThis is the only Connect endpoint that opens a client config file, so\non macOS it is the sole place an App-Data privacy prompt may legitimately\nappear (scoped to this user action). Resolves access_state to\naccessible|absent|denied|malformed and populates remediation when denied.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get a single client's connection status (on-demand)","tags":["connect"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.\nOptionally accepts precondition_token from a preview (Spec 091): when supplied,\nthe core rechecks the raw pre-write state and the entry it would write, and\nrefuses a drifted write with 409 before taking any backup. The 409 body's\naction discriminates the two conflict kinds: \"precondition_failed\" (stale\npreview — re-preview, do not retry) vs \"already_exists\" (entry present — pass\nforce=true). force=true never rescues a stale token.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters (server_name, force, precondition_token)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectConflictResponse"}}},"description":"Conflict: action=already_exists (use force=true) or action=precondition_failed (preview is stale; re-preview)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Connect MCPProxy to a client","tags":["connect"]}},"/api/v1/connect/{client}/preview":{"get":{"description":"Returns the exact entry a subsequent connect would add to the client's\nconfig — target path, server key, entry name, and entry contents — WITHOUT\nmodifying the file or creating a backup (Spec 078 US1). The embedded API key\nis masked in the payload; contains_api_key flags that a credential is written.\nentry_exists distinguishes a create from an overwrite of a same-named entry.\nReads the config on demand to classify create-vs-overwrite, so on macOS this\nmay raise an App-Data privacy prompt; a denial returns 403 + remediation.\nSpec 091 adds three fields: existing_entry_summary (present only when\nentry_exists — a sanitized, non-secret projection of the entry being replaced:\nits name, type, endpoint with query/userinfo stripped, command, and header and\nenv NAMES, never values); precondition_token (always present — an opaque keyed\ndigest of the raw pre-write state and the pending entry, echoed back on POST\nconnect to detect drift); and connect_refusal (present when the write would\nrefuse regardless of intent, e.g. a non-create-capable client with no config —\ntreat its presence as \"Connect unavailable\").","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Entry name to preview (defaults to mcpproxy); mirror the value passed to POST connect","in":"query","name":"server_name","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectPreview"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview the change a connect would make (no write)","tags":["connect"]}},"/api/v1/connect/{client}/undo":{"post":{"description":"Reverts the connect that produced the named backup (Spec 078 US3):\nrestores the client config byte-for-byte from that backup, or — when\nbackup_name is empty because the connect created the file — deletes the\ncreated file. backup_name is the bare filename of the backup the connect\nreturned (never a path); undo resolves the full path server-side inside\nthe client's own config directory, so a client value cannot escape it.\nRefuses with 409 when the config changed since the connect (undo never\nclobbers later edits; use DELETE /connect/{client} for a surgical entry\nremoval instead). Takes its own safety backup first; its path is returned\nas backup_path in the result.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UndoConnectRequest"}}},"description":"Undo parameters (server_name, backup_name = the bare filename of the backup the preceding connect returned)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult (action restored|deleted)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (e.g. backup_name is a path, or not a backup of this client's config)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or backup no longer exists"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Config changed since connect; undo refused"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Undo a connect, restoring the pre-connect config","tags":["connect"]}},"/api/v1/diagnostics":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/docker/status":{"get":{"description":"Retrieve current Docker availability and recovery status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Docker status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get Docker status","tags":["docker"]}},"/api/v1/doctor":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/feedback":{"post":{"description":"Submit a bug report, feature request, or general feedback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackRequest"}}},"description":"Feedback request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Bad Request"},"429":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyAuth":[]}],"summary":"Submit feedback","tags":["feedback"]}},"/api/v1/index/search":{"get":{"description":"Search across all upstream MCP server tools using BM25 keyword search","parameters":[{"description":"Search query","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Maximum number of results","in":"query","name":"limit","schema":{"default":10,"maximum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchToolsResponse"}}},"description":"Search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing query parameter)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search for tools","tags":["tools"]}},"/api/v1/info":{"get":{"description":"Get essential server metadata including version, web UI URL, endpoint addresses, and update availability\nThis endpoint is designed for tray-core communication and version checking\nUse refresh=true query parameter to force an immediate update check against GitHub\nThe launched_by field reports durable launch provenance (\"tray\", \"installer\", or \"\" for user-launched/unknown)","parameters":[{"description":"Force immediate update check against GitHub","in":"query","name":"refresh","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Server information with optional update info"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server information","tags":["status"]}},"/api/v1/onboarding/mark":{"post":{"description":"Updates wizard engagement and per-step status. Once engaged is\ntrue, the wizard does not auto-show again, even if state regresses.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.OnboardingMarkRequest"}}},"description":"Mark request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Mark onboarding wizard state (Spec 046)","tags":["onboarding"]}},"/api/v1/onboarding/state":{"get":{"description":"Returns the wizard engagement record alongside live predicates\n(whether any client is connected, whether any server is configured),\nplus a derived ShouldShowWizard flag the frontend can rely on.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"OnboardingStateResponse"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get onboarding wizard state and predicates (Spec 046)","tags":["onboarding"]}},"/api/v1/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 (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"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Set the default active profile","tags":["profiles"]}},"/api/v1/registries":{"get":{"description":"Retrieves list of all MCP server registries that can be browsed for discovering and installing new upstream servers. Includes registry metadata, server counts, and API endpoints.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetRegistriesResponse"}}},"description":"Registries retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to list registries"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List available MCP server registries","tags":["registries"]},"post":{"description":"Adds a generic modelcontextprotocol/registry v0.1 https endpoint as a custom registry (MCP-866). The source is always tagged custom/unverified, so every server discovered through it lands quarantined and can never skip quarantine.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddRegistrySourceRequest"}}},"description":"Registry source (https url + optional protocol/id/name)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source added"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/contracts.ErrorResponse"},{"$ref":"#/components/schemas/contracts.ErrorResponse"}]}}},"description":"Forbidden (agent tokens cannot mutate registries)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin | duplicate_registry"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a user-supplied registry source","tags":["registries"]}},"/api/v1/registries/{id}":{"delete":{"description":"Removes a custom/unverified registry previously added via add-source (MCP-1057). Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source removed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove a user-added custom registry source","tags":["registries"]},"put":{"description":"Updates a custom registry previously added via add-source (MCP-1072): name, url, servers-url. Empty fields are left unchanged. Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found; a non-https url yields invalid_registry_url. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.EditRegistrySourceRequest"}}},"description":"Fields to update (name/url/servers_url; empty = unchanged)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required | invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Edit a user-added custom registry source","tags":["registries"]}},"/api/v1/registries/{id}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot add servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove an upstream server","tags":["servers"]},"patch":{"description":"Update specific fields of an existing upstream MCP server configuration.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Fields to update (all optional)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server updated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - no fields or invalid body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/config-to-secret":{"post":{"description":"Atomically reads the real value from the server config, stores it in the OS keyring, and rewrites the config field to ` + "`" + `${keyring:\u003cname\u003e}` + "`" + `. Unblocks the UI's Convert-to-secret affordance for values the API redacts on the read path.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored, config updated with reference"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad scope/key/secret_name, or value is already a reference / empty"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server or key not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver or config update failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Convert a header / env value to a keyring secret","tags":["servers"]}},"/api/v1/servers/{id}/disable":{"post":{"description":"Disable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server disabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/discover-tools":{"post":{"description":"Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool discovery triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot discover tools)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to discover tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Discover tools for a specific server","tags":["servers"]}},"/api/v1/servers/{id}/enable":{"post":{"description":"Enable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server enabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/login":{"post":{"description":"Initiate OAuth authentication flow for a specific upstream MCP server. Returns structured OAuth start response with correlation ID for tracking.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthStartResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthFlowError"}}},"description":"OAuth error (client_id required, DCR failed, etc.)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Trigger OAuth login for server","tags":["servers"]}},"/api/v1/servers/{id}/logout":{"post":{"description":"Clear OAuth authentication token and disconnect a specific upstream MCP server. The server will need to re-authenticate before tools can be used again.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"OAuth logout completed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled or read-only mode)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Clear OAuth token and disconnect server","tags":["servers"]}},"/api/v1/servers/{id}/logs":{"get":{"description":"Retrieve log entries for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Number of log lines to retrieve","in":"query","name":"tail","schema":{"default":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerLogsResponse"}}},"description":"Server logs retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server logs","tags":["servers"]}},"/api/v1/servers/{id}/quarantine":{"post":{"description":"Place a specific upstream MCP server in quarantine to prevent tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server quarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Quarantine a server","tags":["servers"]}},"/api/v1/servers/{id}/refresh":{"post":{"description":"Re-discover and re-index a specific upstream MCP server's tools without changing any security state. Alias of discover-tools, named for the upstream_servers 'refresh' operation; use it to make just-approved tools searchable immediately.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool refresh triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot refresh)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Refresh a server's tools","tags":["servers"]}},"/api/v1/servers/{id}/restart":{"post":{"description":"Restart the connection to a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server restarted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/tool-calls":{"get":{"description":"Retrieves tool call history filtered by upstream server ID. Returns recent tool executions for the specified server including timestamps, arguments, results, and errors. Useful for server-specific debugging and monitoring.","parameters":[{"description":"Upstream server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolCallsResponse"}}},"description":"Server tool calls retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get server tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history for specific server","tags":["tool-calls"]}},"/api/v1/servers/{id}/tools":{"get":{"description":"Retrieve all available tools for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolsResponse"}}},"description":"Server tools retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/block":{"post":{"description":"Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The \"blocked\" field counts tools actually blocked.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Block result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Block (approve+disable) tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/disable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/enable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/unquarantine":{"post":{"description":"Remove a specific upstream MCP server from quarantine to allow tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server unquarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Unquarantine a server","tags":["servers"]}},"/api/v1/sessions":{"get":{"description":"Retrieves paginated list of active and recent MCP client sessions. Each session represents a connection from an MCP client to MCPProxy, tracking initialization time, tool calls, and connection status.","parameters":[{"description":"Maximum number of sessions to return (1-100, default 10)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of sessions to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter by session status","in":"query","name":"status","schema":{"enum":["active","closed"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid status filter"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get sessions"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get active MCP sessions","tags":["sessions"]}},"/api/v1/sessions/{id}":{"get":{"description":"Retrieves detailed information about a specific MCP client session including initialization parameters, connection status, tool call count, and activity timestamps.","parameters":[{"description":"Session ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionDetailResponse"}}},"description":"Session details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get MCP session details by ID","tags":["sessions"]}},"/api/v1/stats/tokens":{"get":{"description":"Retrieve token savings statistics across all servers and sessions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Token statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get token savings statistics","tags":["stats"]}},"/api/v1/status":{"get":{"description":"Get comprehensive server status including running state, listen address, upstream statistics, and timestamp","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server status","tags":["status"]}},"/api/v1/telemetry/payload":{"get":{"description":"Render the exact JSON heartbeat payload that mcpproxy would next send to the telemetry endpoint, without making a network call. Counters in the payload reflect the current in-memory state. Spec 042.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Telemetry heartbeat payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/telemetry/update-failure":{"post":{"description":"Records one terminal update-session failure, identified only by its\nstage (appcast, download, install, other). The body carries no error\ntext, URL, or version — the stage is the only value transmitted.\nReturns 204 both when the occurrence was durably persisted and when\ntelemetry is inactive at event time (config opt-out, environment\nopt-out, CI, or dev build), in which case nothing is recorded.\nCallers cannot and need not distinguish the two.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UpdateFailureRequest"}}},"description":"Update failure stage","required":true},"responses":{"204":{"description":"Accepted (recorded, or a deliberate no-op while telemetry is inactive)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Malformed body, unknown field, trailing value, or stage outside the closed set"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Persistence failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Record a desktop auto-update failure occurrence (Spec 095)","tags":["telemetry"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}}, "openapi": "3.1.0" }` diff --git a/oas/swagger.yaml b/oas/swagger.yaml index d661c2ba..81866c36 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -2029,6 +2029,130 @@ components: 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 + duplicate 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) + while 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 + semaphore 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 ":" id. A malformed id is answered with a + per-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 + proxy-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 + never crosses a scope boundary and never names a quarantined server's + tools. + items: + type: string + type: array + uniqueItems: false + hash: + description: |- + Hash is the tool's current pin ("sha256/v{N}:{hex}") — operator tier, + ready 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: @@ -2470,6 +2594,19 @@ components: available without a second round-trip to the approvals endpoint. Absent in 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 + format "sha256/v{N}:{hex}" (Spec 098 FR-011), where N is the approval + record's HashSchemaVersion. It is the authoring surface for + `POST /api/v1/preflight` pins and `mcpproxy tools preflight --pin`: + copy the value straight into a pin. + + Disclosure is OPERATOR TIER ONLY — same rule as the preflight per-tool + result. The field is omitted for agent-token callers and for tools with + no stored hash (no approval record yet, or a record written before + hashes existed). + type: string held_reason: description: |- HeldReason, HeldVerdict and HeldSignals mirror the same-named fields on @@ -3231,6 +3368,7 @@ paths: - system_stop - internal_tool_call - config_change + - preflight type: string - description: Filter by server name in: query @@ -4549,6 +4687,65 @@ paths: 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 (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 From c1bbdc5a8409504bb8f7c29ef0622eabe2e5e3b6 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sat, 15 Aug 2026 20:47:00 +0300 Subject: [PATCH 6/9] docs(preflight): feature page, REST/CLI reference, agent-workflow examples (098) Related #969 docs/features/tools-preflight.md (taxonomy, precedence, tiers, cron/GHA/ n8n recipes, code-exec composition), rest-api.md + CLI reference, README agent-workflow section (discover/call/audit + preflight-gated automation). Spec amendments from live verification: existence source, scope-silent server_not_configured. --- README.md | 67 ++++ docs/api/rest-api.md | 156 ++++++++- docs/cli-management-commands.md | 105 ++++++ docs/features/tools-preflight.md | 300 ++++++++++++++++++ specs/098-tools-preflight/spec.md | 4 +- .../01-activity-list-preflight.png | Bin 0 -> 116811 bytes .../verification/02-type-filter-menu.png | Bin 0 -> 127519 bytes .../03-activity-detail-preflight.png | Bin 0 -> 142638 bytes .../verification/preflight-activity.spec.ts | 42 +++ .../verification/report.html | 42 +++ 10 files changed, 712 insertions(+), 4 deletions(-) create mode 100644 docs/features/tools-preflight.md create mode 100644 specs/098-tools-preflight/verification/01-activity-list-preflight.png create mode 100644 specs/098-tools-preflight/verification/02-type-filter-menu.png create mode 100644 specs/098-tools-preflight/verification/03-activity-detail-preflight.png create mode 100644 specs/098-tools-preflight/verification/preflight-activity.spec.ts create mode 100644 specs/098-tools-preflight/verification/report.html diff --git a/README.md b/README.md index a2031ea5..41eb3916 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,72 @@ See [Configuration](https://docs.mcpproxy.app/configuration/config-file/) and [U --- +## How AI Agents Work Through MCPProxy + +Once connected, your agent sees a handful of built-in MCPProxy tools instead of hundreds of upstream schemas. A typical session has three beats — discover, call, audit — plus an optional preflight gate for unattended automations. + +### 1. Discover — spend one query, not your context window + +The agent asks for what it needs in plain keywords via `retrieve_tools`: + +```json +{ "query": "create github issue", "limit": 5 } +``` + +MCPProxy runs a BM25 search across every connected server and returns only the top-ranked matches — each with a `call_with` hint recommending the right call variant for its annotations: + +```json +{ + "tools": [ + { "name": "github:create_issue", "score": 0.89, "call_with": "call_tool_write" }, + { "name": "gitlab:create_issue", "score": 0.72, "call_with": "call_tool_write" } + ] +} +``` + +This is where the token savings come from: the schemas of the hundreds of tools the agent *didn't* need never enter its context. The agent loads full schemas on demand with `describe_tool` (batch up to 5 ids) only for the tools it's about to use. + +### 2. Call — with declared intent + +The agent executes the tool through the variant matching its intent (`call_tool_read`, `call_tool_write`, or `call_tool_destructive`), addressing it as `server:tool`: + +```json +{ + "name": "github:create_issue", + "args_json": "{\"repo\": \"acme/api\", \"title\": \"Bug report\"}", + "intent": { "operation_type": "write", "reason": "Filing bug per user request" } +} +``` + +MCPProxy validates the intent against the tool's annotations (a "read" call can't reach a destructive tool), checks quarantine and approval state, and scans arguments and responses for sensitive data before anything leaves the machine. + +### 3. Audit — every call is on the record + +Every call lands in the local [Activity Log](https://docs.mcpproxy.app/features/activity-log/) with a request ID, so you can reconstruct exactly what an agent did: + +```bash +mcpproxy activity list # everything, newest first +mcpproxy activity list --request-id # one workflow, correlated +``` + +### Gate automations before they burn tokens + +For recurring headless jobs (cron, CI, n8n), don't let the agent discover a missing tool the expensive way. One preflight command checks that every required tool is ready — without contacting any upstream server — and reports exactly why when it isn't (server quarantined, tool changed since approval, OAuth expired, typo'd id): + +```bash +mcpproxy tools preflight gh-ops:sync_issues slack:post_message --wait 10s +case $? in + 0) run-agent-session ;; # all ready — go + 10) exit 75 ;; # transient (server starting) — let the next cron tick retry + 11) page-operator ;; # blocked — someone must approve / enable / log in + 12) fail-pipeline ;; # unknown tool id — the automation itself is misconfigured +esac +``` + +See [Required-Tools Preflight](https://docs.mcpproxy.app/features/tools-preflight/) for the full reason taxonomy, REST endpoint, and GitHub Actions / n8n recipes. + +--- + ## 🔐 Optional HTTPS Setup MCPProxy works with HTTP by default for easy setup. HTTPS is optional and primarily useful for production environments or when stricter security is required. @@ -295,6 +361,7 @@ curl -k https://localhost:8080/api/v1/status - [OAuth Authentication](https://docs.mcpproxy.app/features/oauth-authentication/) - [Code Execution](https://docs.mcpproxy.app/features/code-execution/) - [Activity Log](https://docs.mcpproxy.app/features/activity-log/) +- [Required-Tools Preflight](https://docs.mcpproxy.app/features/tools-preflight/) - [Agent Tokens](https://docs.mcpproxy.app/features/agent-tokens/) - [Sensitive Data Detection](https://docs.mcpproxy.app/features/sensitive-data-detection/) diff --git a/docs/api/rest-api.md b/docs/api/rest-api.md index e39194fd..ea5b6a4d 100644 --- a/docs/api/rest-api.md +++ b/docs/api/rest-api.md @@ -602,9 +602,161 @@ search/filter/sort over the full set. For relevance-ranked discovery use server cannot be read the endpoint still returns every tool it could gather and sets `partial: true` with `failed_servers` (it does not fail the whole request). +Operator-tier callers (admin API key, Unix socket, named pipe) additionally +receive each approved tool's current schema-hash pin in `hash` +(`sha256/v{N}:{hex}`) — the authoring surface for `POST /api/v1/preflight` +pins. Agent tokens never receive hashes. + #### GET /api/v1/servers/{name}/tools -List tools for a specific server. +List tools for a specific server. Carries the same operator-tier `hash` pin +field as the global listing. + +#### POST /api/v1/preflight + +Required-tools preflight (Spec 098): a deterministic, side-effect-free +availability check for a caller-supplied list of tool IDs. It performs **zero +upstream calls** and mutates no runtime state — verdicts are computed from +local state only (tool index, approval records, connection-state snapshot, +config policy). The HTTP status reports whether the **check executed**, never +what it found: a fully blocked set is still a `200` carrying +`verdict: "blocked"` in the body. See +[Required-Tools Preflight](../features/tools-preflight.md) for the feature +guide and `mcpproxy tools preflight` for the CLI wrapper. + +**Request Body:** +```json +{ + "tools": [ + { "id": "gh-ops:sync_issues" }, + { "id": "ctl:echo", "pin_hash": "sha256/v1:9f86d081884c7d65..." } + ], + "profile": "work", + "policy": { "read_only_only": true }, + "wait_ms": 5000 +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `tools` | array | Required, 1–100 entries — the limit applies to the **raw** array, before dedup. Each entry carries `id` (`:`) and optional `pin_hash`. Duplicate IDs are deduplicated (one result per unique ID); duplicates carrying **different** `pin_hash` values are a `400`. | +| `profile` | string | Optional. Evaluate under this profile's server scope so verdicts match a profile-pinned session's view. Unknown profile: `400`. Omitted: unscoped operator view. | +| `policy` | object | Optional annotation filters, Spec 094 semantics: `read_only_only`, `exclude_destructive`, `exclude_open_world` (evaluated in that fixed order; the first excluding filter owns the verdict). | +| `wait_ms` | integer | Optional, 0–10000. Poll local state while every failure is retryable-class (see below). Values over the cap are a `400`, not a silent clamp. | + +**Response** (standard `APIResponse{data}` envelope): +```json +{ + "success": true, + "data": { + "verdict": "blocked", + "checked_at": "2026-08-15T06:00:00Z", + "waited_ms": 0, + "tools": [ + { + "id": "gh-ops:sync_issues", + "status": "ready", + "hash": "sha256/v1:9f86d081884c7d65..." + }, + { + "id": "slack:post_message", + "status": "unavailable", + "reason": "server_disabled", + "retryable": false, + "action": "enable", + "detail": "Server \"slack\" is disabled.", + "remediation": "Enable the server (mcpproxy upstream enable )." + } + ] + } +} +``` + +Results are ordered by first occurrence of each unique ID in the request. A +`ready` result omits all failure fields — `ready` is a status, not a reason. An +`action` with no value is **omitted**, not `"none"` (matching the health-action +vocabulary). A malformed ID (missing the `:` separator) gets a **per-ID** +`not_found` with a format hint in `detail`, never a request-level error — one +bad entry cannot mask verdicts for the rest. `not_found` results may carry +`did_you_mean` (up to 3 nearest caller-visible IDs). `waited_ms` is present +whenever `wait_ms` was requested, including as `0` (see wait semantics). + +**Failure reasons** (closed enum, Spec 098 FR-003). Evolution is additive-only; +treat unknown codes as non-retryable. `server_saturated` is reserved and never +emitted. When multiple states co-occur for one ID, exactly one reason is +reported per the fixed precedence order (server-level states before tool-level; +see the feature page). + +| `reason` | `retryable` | Default `action` | Set verdict | CLI exit | +|---|---|---|---|---| +| `server_initializing` | true | — (omitted) | `degraded_retryable` | 10 | +| `server_unhealthy` | true | best-effort from diagnostics (`restart`/`login`/`view_logs`; default `view_logs`) | `degraded_retryable` | 10 | +| `server_disabled` | false | `enable` | `blocked` | 11 | +| `server_quarantined` | false | `approve` | `blocked` | 11 | +| `tool_pending_approval` | false | `approve` | `blocked` | 11 | +| `tool_changed` | false | `approve` | `blocked` | 11 | +| `tool_blocked_by_user` | false | `enable` | `blocked` | 11 | +| `oauth_required` | false | `login` | `blocked` | 11 | +| `hash_mismatch` | false | `configure` | `blocked` | 11 | +| `server_not_in_scope` (operator tier only) | false | `configure` | `blocked` | 11 | +| `tool_denied_by_config` | false | `configure` | `blocked` | 11 | +| `missing_annotation` | false | `configure` | `blocked` | 11 | +| `policy_filtered` | false | — (omitted) | `blocked` | 11 | +| `not_found` | false | `configure` | `unknown_ids` | 12 | +| `server_not_configured` | false | `configure` | `unknown_ids` | 12 | + +The set-level `verdict` is the worst class present: +`unknown_ids` > `blocked` > `degraded_retryable` > `ready`. + +**Status codes:** + +- `200` — the check executed; the availability verdict is data in the body. +- `400` — validation error: invalid JSON, empty or oversized (>100 raw entries) + `tools`, a duplicate ID with conflicting `pin_hash` values, `wait_ms` out of + range, or an unknown `profile`. +- `401` — missing or invalid credentials. +- `503` — the check could not run honestly: the runtime is unavailable, an + index/storage/snapshot read failed (reduced-fidelity verdicts are never + emitted), or the activity record could not be persisted. + +A request rejected with `400`/`503` executed no preflight and writes **no** +activity record. + +**`wait_ms` semantics:** polling happens only while **every** current failure +is retryable-class (`server_initializing` / `server_unhealthy`). The endpoint +re-evaluates local state on a floor interval of ≥250 ms until every tool is +ready, a non-retryable failure appears (waiting cannot help, so it resolves +immediately), or the deadline passes; it always resolves with current reasons — +never hangs. Waiting capacity is a small fixed semaphore (4 slots) dedicated to +preflight; when it is exhausted the request degrades gracefully — it resolves +immediately with current verdicts and `waited_ms: 0` instead of queuing or +failing. + +**Disclosure tiers:** + +- **Operator tier** (admin API key, Unix socket, Windows named pipe): full + results — `hash` pins on ready results, `did_you_mean` suggestions, and the + `server_not_in_scope` diagnosis when a supplied `profile` excludes an + existing server (with a `detail` noting that a session under that profile + sees `not_found`). +- **Agent-token tier**: scope-silence — an out-of-scope ID's entire result is + byte-indistinguishable from an ordinary `not_found` (same wording; no hashes; + no `did_you_mean` crossing the scope boundary). `did_you_mean` is computed + over the caller-visible index only and never suggests a quarantined server's + tools. + +**Activity-record guarantee:** every request answered `200` writes an activity +record **synchronously, before the response is returned** — request ID, +requested-ID count, set verdict, and per-tool reason codes (tool IDs and enum +codes only; no descriptions, no arguments, no hashes; local-only, never +telemetry). Correlate via the `X-Request-Id` response header and +`mcpproxy activity list --request-id `. + +**Hash pins** (`pin_hash`): format `sha256/v{N}:{hex}`. The hash schema version +is embedded so a proxy-side hash-algorithm bump is distinguishable from genuine +upstream drift (both report `hash_mismatch`, with different `detail`). Current +pins are discoverable on ready preflight results and on the operator-tier tool +listings above. ### Registries @@ -1008,7 +1160,7 @@ List activity records with filtering and pagination. | Parameter | Type | Description | |-----------|------|-------------| -| `type` | string | Filter by type: `tool_call`, `policy_decision`, `quarantine_change`, `server_change` | +| `type` | string | Filter by type: `tool_call`, `policy_decision`, `quarantine_change`, `server_change`, `preflight` | | `server` | string | Filter by server name | | `tool` | string | Filter by tool name | | `session_id` | string | Filter by MCP session ID | diff --git a/docs/cli-management-commands.md b/docs/cli-management-commands.md index 186dbeb9..e165ecc5 100644 --- a/docs/cli-management-commands.md +++ b/docs/cli-management-commands.md @@ -738,12 +738,117 @@ mcpproxy tools reject --server github --all --- +### `mcpproxy tools preflight [...]` + +Check that required tools are ready — deterministically, side-effect-free, and +**without calling any upstream server** — before a cron/CI job spends model +tokens finding out the hard way (Spec 098). Wraps `POST /api/v1/preflight`; the +check reads the tool index, approval records, connection state and config +policy only, and changes nothing. Requires daemon. The exit code is the +product: a wrapper can branch retry-vs-page-vs-fix on it without parsing any +JSON. See [Required-Tools Preflight](features/tools-preflight.md). + +**Usage:** +```bash +mcpproxy tools preflight [...] [flags] +``` + +**Flags:** +- `--profile ` - Evaluate under a named profile's server scope (unknown profile fails with exit 1) +- `--pin =sha256/v:` - Pin a tool to a schema hash; a divergence reports `hash_mismatch` (repeatable; each pinned id must be in the requested list). Current pins come from `mcpproxy tools list -o json` (`hash` field, operator tier) +- `--read-only-only` - Require tools to be annotated read-only +- `--exclude-destructive` - Require tools to be annotated non-destructive +- `--exclude-open-world` - Require tools to be annotated closed-world +- `--wait ` - Poll local state for up to this long while every failure is retryable (max `10s`; larger values are rejected by the daemon) +- `--output, -o` - Output format: `table`, `json`, `yaml` (or `MCPPROXY_OUTPUT`); `--help-json` for machine-readable command metadata + +**Exit codes** (worst class present wins): + +| Exit | Verdict | Meaning | Wrapper action | +|------|---------|---------|----------------| +| `0` | `ready` | Every tool is ready | Proceed | +| `10` | `degraded_retryable` | A server is starting up or unhealthy | Back off and retry | +| `11` | `blocked` | Operator action needed (approve, enable, log in, re-pin) | Page the operator | +| `12` | `unknown_ids` | At least one requested id is unknown in your view (typo, removed server) | Fix the job's tool list | +| `1` | — | The command itself failed (daemon unreachable, invalid arguments, rejected request) | Investigate | + +**Examples:** +```bash +mcpproxy tools preflight gh-ops:sync_issues slack:post_message +mcpproxy tools preflight ctl:echo -o json +mcpproxy tools preflight ctl:echo --pin ctl:echo=sha256/v1:9f86d081884c7d65... +mcpproxy tools preflight ctl:echo --profile work --wait 5s +mcpproxy tools preflight ctl:echo --read-only-only +``` + +**Output:** +- `table` (default): a `VERDICT: (exit )` / `CHECKED: