From b9c6e618785d40fe5c485397f2ef02e7171dc118 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Mon, 25 May 2026 21:58:08 +0300 Subject: [PATCH 1/3] docs(056): output-schema-validation spec, plan, tasks (Spec 054 Track A) Related #521 Carve Track A of the Spec 054 security-gateway umbrella into its own feature: validate a tool's structuredContent against its declared outputSchema at the proxy boundary before it reaches the agent. ## Changes - spec.md: FR-A1..A12, 3 prioritized user stories, edge cases, success criteria - plan.md: pure internal/outputvalidation pkg + forwardContentResult hook design - research.md: santhosh-tekuri/jsonschema/v6, capture point, modes, cache decisions - data-model.md, contracts/validator.md, quickstart.md - tasks.md: 24 TDD-first tasks organized by user story --- .../checklists/requirements.md | 36 +++++ .../contracts/validator.md | 89 +++++++++++++ .../data-model.md | 75 +++++++++++ specs/056-output-schema-validation/plan.md | 80 +++++++++++ .../quickstart.md | 61 +++++++++ .../056-output-schema-validation/research.md | 67 ++++++++++ specs/056-output-schema-validation/spec.md | 124 ++++++++++++++++++ specs/056-output-schema-validation/tasks.md | 115 ++++++++++++++++ 8 files changed, 647 insertions(+) create mode 100644 specs/056-output-schema-validation/checklists/requirements.md create mode 100644 specs/056-output-schema-validation/contracts/validator.md create mode 100644 specs/056-output-schema-validation/data-model.md create mode 100644 specs/056-output-schema-validation/plan.md create mode 100644 specs/056-output-schema-validation/quickstart.md create mode 100644 specs/056-output-schema-validation/research.md create mode 100644 specs/056-output-schema-validation/spec.md create mode 100644 specs/056-output-schema-validation/tasks.md diff --git a/specs/056-output-schema-validation/checklists/requirements.md b/specs/056-output-schema-validation/checklists/requirements.md new file mode 100644 index 00000000..dc53d26f --- /dev/null +++ b/specs/056-output-schema-validation/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Output-Schema Validation for Proxied Tool Calls + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-25 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Spec deliberately names integration points (`forwardContentResult`, `emitActivityPolicyDecision`) in the **Assumptions** section only, as grounding for the planning phase — the normative requirements (FR-Ax) and success criteria remain implementation-agnostic. +- Scope is tightly bounded to Spec 054 Track A; Tracks B–E explicitly listed under Out of Scope. +- Ready for `/speckit.plan`. diff --git a/specs/056-output-schema-validation/contracts/validator.md b/specs/056-output-schema-validation/contracts/validator.md new file mode 100644 index 00000000..2cc71733 --- /dev/null +++ b/specs/056-output-schema-validation/contracts/validator.md @@ -0,0 +1,89 @@ +# Contract: `internal/outputvalidation` package + config + +Pure package, no server/storage imports. Unit-testable in isolation. + +## Validator interface + +```go +package outputvalidation + +// Validator validates a tool's structured output against its declared JSON Schema, +// applying size/depth guards first. Safe for concurrent use. +type Validator struct { + maxBytes int + maxDepth int + cache sync.Map // key: cacheKey{toolKey, schemaHash} -> *compiled (or sentinel) + logger *zap.Logger +} + +func New(maxBytes, maxDepth int, logger *zap.Logger) *Validator + +// Validate checks `structured` (the decoded CallToolResult.StructuredContent, may be nil) +// against schemaJSON for the tool identified by toolKey ("server:tool"). +// +// - schemaJSON == "" -> Verdict{OutcomePass} (FR-A7 no-op) +// - structured == nil -> Verdict{OutcomePass} (FR-A8 nothing-to-validate; caller applies +// missing_structured_content posture in strict) +// - schema uncompilable -> Verdict{OutcomePass} + one-time warn (FR-A9) +// - byte/depth guard breach -> Verdict{OutcomeViolate, GuardHit:...} (FR-A6, no schema validation done) +// - schema violation -> Verdict{OutcomeViolate, Reason:...} (FR-A2) +// - valid -> Verdict{OutcomePass} (FR-A3 caller forwards unchanged) +func (v *Validator) Validate(toolKey, schemaJSON string, structured any) Verdict +``` + +`Validate` MUST NOT mutate `structured`. The caller passes the already-decoded `StructuredContent`; the validator only reads it. Guards run on a one-time `json.Marshal` (byte size) + recursive walk (depth) of the value. + +## Verdict + +```go +type Outcome int +const ( OutcomePass Outcome = iota; OutcomeViolate ) + +type Verdict struct { + Outcome Outcome + Reason string // violation detail (keyword + instance path), "" on pass + GuardHit string // "", "max_bytes", or "max_depth" +} + +func (v Verdict) IsViolation() bool { return v.Outcome == OutcomeViolate } +``` + +## Caller contract (server side) + +In `handleCallToolVariant` (`mcp.go`), after the upstream call and before/at `forwardContentResult`: + +``` +if cfg.OutputValidation.IsEnabled() && !result.IsError { // FR-A10 skip errors + schema := lookupOutputSchemaJSON(server, tool) // from ToolMetadata.OutputSchemaJSON + verdict := validator.Validate(server+":"+tool, schema, result.StructuredContent) + switch { + case !verdict.IsViolation(): + // forward unchanged (FR-A3) + case verdict.OutcomeViolate && cfg.OutputValidation.IsStrict(): + emitActivityPolicyDecision(server, tool, sid, "blocked", verdict.Reason) // FR-A5 + return mcp.NewToolResultError("output schema validation failed: " + verdict.Reason) + default: // warn + emitActivityPolicyDecision(server, tool, sid, "warning", verdict.Reason) // FR-A5/FR-A11 + // forward original payload unchanged + } +} +``` + +Missing `structuredContent` in strict mode + `missing_structured_content=block` ⇒ caller blocks even though `Validate` returned Pass (the posture decision lives with the caller, which knows the mode). + +## Config contract (`mcp_config.json`) + +```json +{ + "output_validation": { + "mode": "warn", + "max_bytes": 5242880, + "max_depth": 64, + "missing_structured_content": "allow" + } +} +``` + +- Absent block ⇒ defaults (mode `warn`). +- `mode: "off"` ⇒ validator never invoked (FR-A4); zero added overhead (SC-006). +- Backward compatible: existing configs without the block keep working, gaining warn-mode audit only for tools that declare an output schema. diff --git a/specs/056-output-schema-validation/data-model.md b/specs/056-output-schema-validation/data-model.md new file mode 100644 index 00000000..a42a4c2c --- /dev/null +++ b/specs/056-output-schema-validation/data-model.md @@ -0,0 +1,75 @@ +# Phase 1 Data Model: Output-Schema Validation + +No new persistent storage (no BBolt bucket, no migration). Two existing in-memory/config entities are extended and one config entity is added. + +## 1. `ToolMetadata` (extended) — `internal/config/config.go` + +Existing struct gains one field, mirroring `ParamsJSON`. + +```go +type ToolMetadata struct { + // ... existing fields ... + ParamsJSON string `json:"params_json"` // existing: input schema + OutputSchemaJSON string `json:"output_schema_json,omitempty"` // NEW: declared output schema, raw bytes +} +``` + +- **Source**: populated at discovery in `internal/upstream/core/client.go` from `mcp.Tool.RawOutputSchema` (preferred) or marshalled `mcp.Tool.OutputSchema`. +- **Empty** ⇒ tool declares no output schema ⇒ validation is a no-op (FR-A7). +- Carried wherever `ToolMetadata` already travels (index/state); not separately persisted. + +## 2. `OutputValidationConfig` (new) — `internal/config/config.go` + +```go +type OutputValidationConfig struct { + Mode string `json:"mode,omitempty" mapstructure:"mode"` // "off" | "warn" | "strict"; default "warn" + MaxBytes int `json:"max_bytes,omitempty" mapstructure:"max-bytes"` // structured payload byte cap; default 5<<20 + MaxDepth int `json:"max_depth,omitempty" mapstructure:"max-depth"` // nesting depth cap; default 64 + MissingStructuredContent string `json:"missing_structured_content,omitempty" mapstructure:"missing-structured-content"` // "allow" | "block"; default "allow" +} +``` + +- Hung off the root config: `OutputValidation *OutputValidationConfig `json:"output_validation,omitempty"`` (pointer, like `SensitiveDataDetection`). +- `DefaultOutputValidationConfig()` returns `{Mode:"warn", MaxBytes:5<<20, MaxDepth:64, MissingStructuredContent:"allow"}`. +- Helpers: `IsEnabled()` (Mode != "off"), `IsStrict()` (Mode == "strict"), `EffectiveMaxBytes()`/`EffectiveMaxDepth()` (apply defaults when zero). A `nil` pointer behaves as defaults (warn). Hot-reloadable via the existing config watcher. + +**Validation rules**: unknown `Mode`/`MissingStructuredContent` values fall back to the default with a logged warning; non-positive `MaxBytes`/`MaxDepth` fall back to defaults. + +## 3. `Verdict` (new, transient) — `internal/outputvalidation` + +Returned by the validator; never persisted. + +```go +type Outcome int +const ( + OutcomePass Outcome = iota // valid, or no schema, or nothing-to-validate (no-op) + OutcomeViolate // schema or guard violation +) + +type Verdict struct { + Outcome Outcome + Reason string // human-readable violation (keyword + instance location), empty on pass + GuardHit string // "" | "max_bytes" | "max_depth" when a guard tripped +} +``` + +- `Outcome=OutcomePass` always means "forward unchanged" — the caller never mutates the payload. +- `Outcome=OutcomeViolate` ⇒ caller blocks (strict) or forwards + audits (warn) per mode. + +## 4. Validation Failure Record (reused) — activity log + +No new type. `emitActivityPolicyDecision(server, tool, sessionID, decision, reason)`: +- `decision = "blocked"` (strict) or `"warning"` (warn) — `"warning"` is a newly-used value of the existing field. +- `reason = Verdict.Reason` (truncated; no response payload contents). +- Surfaces in `mcpproxy activity list` / `activity show` (SC-005). + +## Entity relationships + +``` +mcp.Tool (upstream, mcp-go) + └─ RawOutputSchema ──capture──▶ ToolMetadata.OutputSchemaJSON + │ (at call time, keyed by server:tool) +OutputValidationConfig ──mode/guards──▶ Validator ──Verdict──▶ forwardContentResult caller (mcp.go) + │ compiled-schema sync.Map cache (server:tool + schema-hash) +CallToolResult.StructuredContent ──validate copy──▶ Verdict ──▶ block | tag+forward ──▶ emitActivityPolicyDecision +``` diff --git a/specs/056-output-schema-validation/plan.md b/specs/056-output-schema-validation/plan.md new file mode 100644 index 00000000..0f95740e --- /dev/null +++ b/specs/056-output-schema-validation/plan.md @@ -0,0 +1,80 @@ +# Implementation Plan: Output-Schema Validation for Proxied Tool Calls + +**Branch**: `056-output-schema-validation` | **Date**: 2026-05-25 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/056-output-schema-validation/spec.md` + +## Summary + +When an upstream tool declares an `outputSchema`, mcpproxy validates the structured portion of every proxied tool-call response against that schema before forwarding it to the agent. A new pure package `internal/outputvalidation` holds a `Validator` with a per-tool compiled-schema cache; the existing single response chokepoint `forwardContentResult` (`internal/server/content_forward.go`) gains an optional validator hook that runs cheap size/depth guards first, then schema validation, never mutating `StructuredContent` on the success path. Violations are turned into a hard block (strict mode) or a forward-with-audit (warn mode, the backward-compatible default). The tool's output schema is captured at discovery (`internal/upstream/core/client.go`) and persisted on `config.ToolMetadata` alongside the existing `ParamsJSON` input schema. + +## Technical Context + +**Language/Version**: Go 1.24 (toolchain go1.24.10) +**Primary Dependencies**: `github.com/santhosh-tekuri/jsonschema/v6` v6.0.2 (already in module graph as indirect — promote to direct); `github.com/mark3labs/mcp-go` v0.54.0 (existing, provides `Tool.RawOutputSchema`/`OutputSchema` and `CallToolResult.StructuredContent`); `go.uber.org/zap` (existing logging) +**Storage**: No new BBolt buckets. Output schema persisted as a JSON string on the existing `config.ToolMetadata` (in-config / index path), mirroring `ParamsJSON`. Validation config lives in `mcp_config.json` (Constitution III). +**Testing**: `go test` unit tests (new `internal/outputvalidation` package + `content_forward` hook); E2E via `scripts/test-api-e2e.sh` extended with a stub upstream declaring an output schema; `go test -race` on touched packages +**Target Platform**: Linux/macOS/Windows core server; personal + server editions identical (no build tags) +**Project Type**: single (Go backend; no frontend work in Track A) +**Performance Goals**: Validation no-op path (no schema / mode=off) adds negligible per-call overhead (Constitution I — no regression on 1000-tool routing); compiled schemas cached so steady-state validation does not recompile +**Constraints**: Never mutate the forwarded `StructuredContent` on success (FR-A3); guards bound cost before validation (FR-A6); uncompilable schema degrades to no-op, never blocks traffic (FR-A9) +**Scale/Scope**: Hot path runs once per proxied `call_tool_*`; cache keyed by `server:tool` + schema hash, bounded by number of distinct tools (≤ ~1000 per Constitution I) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Performance at Scale | ✅ PASS | Guards run before validation; compiled-schema cache avoids recompilation; no-op fast path for the common (no-schema) case. No new locking on the hot path beyond a `sync.Map` read. | +| II. Actor-Based Concurrency | ✅ PASS | Validator is stateless apart from a `sync.Map` cache (read-mostly). No new goroutines; runs inline on the existing call path. `sync.Map` is the idiomatic choice for a read-mostly cache (benchmark-justified exception per principle II). | +| III. Configuration-Driven Architecture | ✅ PASS | New `output_validation` block in `mcp_config.json` with sensible defaults (mode=warn); hot-reloadable via existing config watcher; no tray-side state. | +| IV. Security by Default | ✅ PASS | This **is** a security feature. Default `warn` mode is the deliberate backward-compatible posture (audit without breaking working agents); operators escalate to `strict`. Failures logged with full transparency via existing activity log. | +| V. Test-Driven Development | ✅ PASS | Red-green-refactor: unit tests for the validator + guards written first; E2E stub-server test for the proxy path; `golangci-lint` clean. | +| VI. Documentation Hygiene | ✅ PASS | New `docs/features/output-schema-validation.md`; config reference + CLAUDE.md note (mind the 40k char gate — keep the CLAUDE.md delta to one line). | + +**Gate result**: PASS — no violations, Complexity Tracking not required. + +## Project Structure + +### Documentation (this feature) + +```text +specs/056-output-schema-validation/ +├── plan.md # This file +├── spec.md # Feature spec +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output (config schema + validator interface) +├── checklists/ +│ └── requirements.md +└── tasks.md # Phase 2 (/speckit.tasks) +``` + +### Source Code (repository root) + +```text +internal/ +├── outputvalidation/ # NEW pure package (no server deps) +│ ├── validator.go # Validator: Validate(toolKey, schemaJSON, structured) -> Verdict; compiled-schema sync.Map cache; size/depth guards +│ ├── validator_test.go # Unit: conforming/violating/missing-schema/uncompilable/guard breaches +│ ├── guards.go # byteSize + nestingDepth guards over a decoded value +│ └── guards_test.go +├── config/ +│ └── config.go # ADD: OutputValidation config struct + defaults; ADD ToolMetadata.OutputSchemaJSON +├── upstream/core/ +│ └── client.go # ADD: map mcp.Tool.RawOutputSchema/OutputSchema -> ToolMetadata.OutputSchemaJSON (~line 284) +└── server/ + ├── content_forward.go # ADD: optional validator hook + Verdict handling (block vs tag) without mutating StructuredContent + ├── content_forward_test.go # Unit: success passthrough byte-identical; strict block; warn forward+verdict; IsError skip + └── mcp.go # WIRE: look up captured output schema for server:tool; pass validator + mode into forwardContentResult at :1794 / :2166; emitActivityPolicyDecision on failure + +e2e/ or scripts/ +└── test-api-e2e.sh # EXTEND: stub upstream tool with outputSchema; assert strict-block + warn-forward + activity record +``` + +**Structure Decision**: Single Go project. The validation logic is isolated in a new dependency-free package `internal/outputvalidation` (DDD domain layer per Constitution principle on layering), wired into the existing infrastructure (`content_forward.go` + `mcp.go`) through a narrow interface so the validator stays pure and unit-testable. No frontend changes in Track A. + +## Complexity Tracking + +> No constitution violations — section intentionally empty. diff --git a/specs/056-output-schema-validation/quickstart.md b/specs/056-output-schema-validation/quickstart.md new file mode 100644 index 00000000..3b3e68d7 --- /dev/null +++ b/specs/056-output-schema-validation/quickstart.md @@ -0,0 +1,61 @@ +# Quickstart: Output-Schema Validation + +## Enable it + +In `~/.mcpproxy/mcp_config.json`: + +```json +{ + "output_validation": { + "mode": "warn", + "max_bytes": 5242880, + "max_depth": 64, + "missing_structured_content": "allow" + } +} +``` + +- `warn` (default if the block is absent): violations are forwarded but logged as `policy_decision` activity records. Safe to enable on day one. +- `strict`: violations are blocked with an MCP error returned to the agent. +- `off`: validation disabled entirely. + +## See what it caught + +```bash +mcpproxy activity list --type policy_decision # validation warnings + blocks +mcpproxy activity list --status blocked # strict-mode blocks only +mcpproxy activity show # tool, mode, violation detail +``` + +## How it behaves + +| Tool declares outputSchema? | Response has structuredContent? | Conforms? | warn | strict | +|---|---|---|---|---| +| No | — | — | forward (no-op) | forward (no-op) | +| Yes | No (text only) | — | forward (no-op) | forward, unless `missing_structured_content=block` | +| Yes | Yes | Yes | forward unchanged | forward unchanged | +| Yes | Yes | No | forward + audit | **block** + audit | +| Yes | Yes (oversized / too deep) | — | forward + audit (guard) | **block** + audit (guard) | +| Yes | upstream IsError result | — | forward (skip) | forward (skip) | + +On the conforming path the `structuredContent` delivered to the agent is **byte-for-byte identical** to what the upstream returned. + +## Manual verification (curl, against a running proxy) + +```bash +# 1. strict mode, call a tool whose upstream returns schema-violating structuredContent +curl -s -H "X-API-Key: $KEY" -X POST http://127.0.0.1:8080/mcp \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"call_tool_read","arguments":{"name":"stub:bad_output"}}}' | jq . +# -> expect an error result mentioning "output schema validation failed" + +# 2. confirm the audit record +curl -s -H "X-API-Key: $KEY" "http://127.0.0.1:8080/api/v1/activity?type=policy_decision&limit=5" | jq '.activities[] | {tool,decision,reason}' +``` + +## Tests + +```bash +go test ./internal/outputvalidation/... -v -race # unit: validator + guards +go test ./internal/server/ -run ContentForward -v # unit: chokepoint hook +./scripts/test-api-e2e.sh # e2e: stub upstream w/ outputSchema, strict block + warn forward + activity record +``` diff --git a/specs/056-output-schema-validation/research.md b/specs/056-output-schema-validation/research.md new file mode 100644 index 00000000..a708f23c --- /dev/null +++ b/specs/056-output-schema-validation/research.md @@ -0,0 +1,67 @@ +# Phase 0 Research: Output-Schema Validation + +## Decision 1: JSON Schema validation library + +**Decision**: Use `github.com/santhosh-tekuri/jsonschema/v6` (v6.0.2), promoting it from indirect to a direct dependency. + +**Rationale**: +- Already present in the module graph (pulled transitively), so no new third-party supply-chain surface — only a `go.mod` `require` promotion. +- Most complete and spec-compliant Go JSON Schema implementation: supports draft 2020-12 (the draft MCP `outputSchema` uses), `$ref`, formats, and rich error paths (instance location + keyword location), which we surface in the FR-A5 violation description. +- `Compile`/`Validate` split maps cleanly onto our per-tool compiled-schema cache: compile once, validate per call. + +**Alternatives considered**: +- `github.com/google/jsonschema-go` v0.4.2 (also indirect): newer, but less battle-tested error reporting and fewer draft-coverage guarantees than santhosh-tekuri. +- `github.com/xeipuuv/gojsonschema`: unmaintained, draft-07 only — rejected. +- Hand-rolled structural checks: rejected — reinvents a validator, fails FR-A2 generality. + +## Decision 2: Where to capture the output schema (FR-A1) + +**Decision**: At tool discovery in `internal/upstream/core/client.go` (~line 284, where `mcp.Tool.InputSchema` is already marshalled into `ToolMetadata.ParamsJSON`), also serialize the tool's output schema into a new `ToolMetadata.OutputSchemaJSON string`. Prefer `tool.RawOutputSchema` (raw `json.RawMessage`, preserves the upstream's exact schema bytes); fall back to marshalling `tool.OutputSchema` when raw is absent. + +**Rationale**: Mirrors the existing input-schema persistence path exactly, so the schema is available at call time without a second upstream round-trip and without a new storage bucket. Raw bytes avoid lossy re-encoding and keep a stable hash for the cache key. + +**Alternatives considered**: +- Re-fetch the tool definition at call time: rejected — extra latency, racy with reconnects. +- New BBolt bucket for output schemas: rejected — `ToolMetadata` already travels with the tool; no migration needed. + +## Decision 3: Hook point for validation (FR-A2, FR-A3) + +**Decision**: Add an optional validator hook to `forwardContentResult` in `internal/server/content_forward.go` — the single proxied-response chokepoint, called from `handleCallToolVariant` at `mcp.go:1794` and `:2166`. Validation reads `ctr.StructuredContent`, runs guards then schema validation on a decoded copy, and returns a `Verdict`. On success the original `StructuredContent` is forwarded byte-identically (validate-a-copy, never strip-then-validate). The caller (`mcp.go`) translates the verdict into a block (strict) or a forward + `emitActivityPolicyDecision` (warn). + +**Rationale**: One chokepoint = one place to enforce, consistent with how truncation already works there. Keeping the block/tag decision in `mcp.go` (which owns activity logging + error result construction) keeps `content_forward.go` and the validator pure. + +**Alternatives considered**: +- Validate inside each `handleCallTool*` variant: rejected — duplicated logic across two call sites. +- Validate in the upstream client layer: rejected — the upstream layer must stay transport-only (Constitution 3-layer rule); policy belongs at the proxy boundary. + +## Decision 4: Guards before validation (FR-A6) + +**Decision**: Before compiling/validating, run two cheap guards on the structured payload: (a) marshalled byte size vs `max_bytes`; (b) recursive nesting depth vs `max_depth`. A breach short-circuits to a guard-violation verdict (no schema validation). Defaults: `max_bytes = 5 MiB`, `max_depth = 64` — generous enough never to trip legitimate tool output, tight enough to bound adversarial nesting/DoS. + +**Rationale**: Schema validation cost grows with payload size/nesting; an adversarial deeply-nested blob could be expensive. Guarding first protects the proxy (Constitution I) and gives a clear, cheap failure mode. + +**Alternatives considered**: Validate-then-measure — rejected (does the expensive work on exactly the inputs we want to reject early). + +## Decision 5: Modes & defaults (FR-A4, FR-A8) + +**Decision**: `output_validation.mode` ∈ {`off`, `warn`, `strict`}, default `warn`. `missing_structured_content` ∈ {`allow`, `block`}, default `allow`. In `warn`, violations forward the original payload and emit a `policy_decision` activity record (status e.g. `warning`). In `strict`, violations return an MCP error result and emit a `policy_decision` (status `blocked`). A declared-but-absent `structuredContent` is a no-op in `warn`; in `strict` it follows `missing_structured_content`. + +**Rationale**: `warn` default means turning the feature on never breaks a working agent (the ContextForge #4042 lesson) — operators see audit signal first, then escalate to `strict`. `allow`-by-default for missing structured content matches the many tools that declare an output schema but still return only text. + +**Alternatives considered**: default `strict` — rejected (breaks under-declaring tools on upgrade, violates backward-compat assumption). + +## Decision 6: Schema-compile cache & uncompilable schemas (FR-A9) + +**Decision**: `Validator` holds a `sync.Map` keyed by `server:tool` + FNV/SHA hash of the schema bytes → compiled `*jsonschema.Schema` (or a sentinel "uncompilable"). First call compiles; subsequent calls reuse. If `Compile` fails, store the sentinel, log one diagnostic warning per tool, and treat the tool as no-schema (no-op) thereafter — never block traffic on the proxy's inability to compile a schema. + +**Rationale**: Read-mostly cache → `sync.Map` is the idiomatic, lock-light choice (Constitution II benchmark-justified exception). Hashing the schema bytes invalidates the cache automatically if a tool's schema changes (forward-compatible with Track D pinning, which is out of scope here). + +**Alternatives considered**: `map` + `sync.RWMutex` — acceptable but `sync.Map` better fits the write-once-read-many access pattern. + +## Decision 7: Activity record shape (FR-A5, FR-A11) + +**Decision**: Reuse `emitActivityPolicyDecision(server, tool, sessionID, decision, message)` already used for blocked decisions. Use `decision="blocked"` in strict, `decision="warning"` in warn (add the status if not present), with `message` carrying the validator's violation string (keyword + instance location, truncated). No payload contents are logged beyond the violation description (avoid leaking response data). + +**Rationale**: Zero new logging plumbing; failures appear in `mcpproxy activity list` filterable by status, satisfying SC-005. + +**Alternatives considered**: A bespoke validation-event type — rejected (the activity log's `policy_decision` already models exactly this). diff --git a/specs/056-output-schema-validation/spec.md b/specs/056-output-schema-validation/spec.md new file mode 100644 index 00000000..399d91ea --- /dev/null +++ b/specs/056-output-schema-validation/spec.md @@ -0,0 +1,124 @@ +# Feature Specification: Output-Schema Validation for Proxied Tool Calls + +**Feature Branch**: `056-output-schema-validation` +**Created**: 2026-05-25 +**Status**: Draft +**Input**: Spec 054 Track A (carved into its own feature). When an upstream tool declares an `outputSchema`, mcpproxy verifies the tool's structured response conforms to that schema before it reaches the agent, so a buggy or compromised server cannot inject malformed/oversized/unexpected data into the agent's context. + +> Scope note: this feature is **Track A only** of the Spec 054 umbrella ("MCP Security Gateway Hardening"). It deliberately excludes Track B (output sanitisation), Track C (per-tool ACLs), Track D (TOFU pinning hardening), and Track E (audit hash chain). Those ship separately. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Structured output is validated against its declared schema (Priority: P1) + +As an operator running AI agents through mcpproxy, when an upstream tool declares an `outputSchema`, I want mcpproxy to verify that the tool's structured response actually conforms to that schema before it reaches my agent, so that a buggy or compromised server cannot inject malformed, oversized, or unexpected data into the agent's context. + +**Why this priority**: This is the emptiest axis — mcpproxy does zero output validation today — and the highest-leverage new capability, completing the "validated data out" half of the security story. It is mostly additive with safe defaults, so it can ship as a standalone MVP. + +**Independent Test**: Configure a stub upstream tool with an `outputSchema`. Return a conforming response (must pass through unchanged) and a non-conforming response (blocked in strict mode / forwarded-and-tagged in warn mode, with an activity record emitted). Verify `structuredContent` is preserved verbatim on the pass path (no strip-then-validate). + +**Acceptance Scenarios**: + +1. **Given** a tool with a declared output schema and `output_validation.mode=strict`, **When** it returns `structuredContent` violating the schema, **Then** the call is blocked with a clear error and a `policy_decision` activity record is written. +2. **Given** the same tool in `mode=warn`, **When** it returns a violating response, **Then** the response is forwarded but tagged as schema-violating and a `policy_decision` activity record is written. +3. **Given** a tool with NO declared output schema, **When** it returns any response, **Then** validation is a no-op and behaviour is unchanged (backward compatible). +4. **Given** a valid structured response, **When** it is validated, **Then** the original `structuredContent` reaches the agent unmodified (byte-for-byte). + +--- + +### User Story 2 - Oversized / pathological output is bounded before validation (Priority: P2) + +As an operator, I want extremely large or deeply-nested structured payloads from a tool to be bounded by configurable guards before validation runs, so a single response cannot exhaust memory, blow the agent's context window, or DoS the proxy through schema-validation cost. + +**Why this priority**: A guard is cheap, protects the proxy itself, and is a prerequisite for safely running schema validation (which can be expensive on adversarial nested input). It builds on the same response chokepoint as Story 1. + +**Independent Test**: Configure a byte-size guard and a nesting-depth guard. Return a structured payload exceeding the byte size, and one exceeding the depth. Verify each is treated as a validation failure (blocked in strict, tagged in warn) with an activity record, and that the guard check happens before full schema validation. + +**Acceptance Scenarios**: + +1. **Given** a configured max structured-output byte size, **When** a response's structured payload exceeds it, **Then** the call is treated as a guard violation (blocked in strict / tagged in warn) and a `policy_decision` activity record is written. +2. **Given** a configured max nesting depth, **When** a response's structured payload exceeds it, **Then** the same guard-violation handling applies. +3. **Given** a payload within both guards, **When** it is validated, **Then** guards add negligible overhead and schema validation proceeds. + +--- + +### User Story 3 - Operator can observe and tune validation behaviour (Priority: P3) + +As an operator, I want to configure validation mode and guard limits, and to see validation failures in the activity log alongside other policy decisions, so I can roll the feature out safely (warn first, then strict) and audit what was caught. + +**Why this priority**: Configurability + observability turn a binary feature into one operators trust enough to enable. It reuses the existing activity-log and config plumbing. + +**Independent Test**: Set `output_validation.mode` to `off`, `warn`, and `strict` and confirm behaviour matches each. Confirm `mcpproxy activity list` surfaces the validation `policy_decision` records and that `mcpproxy activity show ` reveals the tool, mode, and violation detail. + +**Acceptance Scenarios**: + +1. **Given** `output_validation.mode=off`, **When** any tool returns structured output, **Then** no validation runs and no validation activity records are written. +2. **Given** a validation failure was recorded, **When** the operator runs `mcpproxy activity list`, **Then** the failure appears as a `policy_decision` record filterable by status. +3. **Given** a validation failure record, **When** the operator inspects it, **Then** it includes the server, tool, mode, and a human-readable description of the violation. + +--- + +### Edge Cases + +- **Legacy text-only response (ContextForge #4042 trap)**: a tool declares an `outputSchema` but returns only legacy text `content` with no `structuredContent`. This MUST NOT hard-fail in warn mode — it is treated as "no structured output to validate" (no-op). In strict mode the configured `missing_structured_content` posture decides (default: allow, to preserve backward compatibility with tools that under-declare). +- **Oversized / deeply-nested payload**: bounded by the size/depth guards (Story 2) *before* schema validation, consistent with existing payload caps; the guard verdict short-circuits expensive validation. +- **Malformed / unparseable schema on the tool**: if the captured `outputSchema` is itself invalid JSON Schema, validation cannot run; treat as no-op + emit a one-time warning per tool (do not block traffic on the proxy's inability to compile a schema). +- **`IsError` upstream result**: when the upstream tool returns an error result, output validation is skipped (there is no successful structured payload to validate). +- **Streaming / multiple content blocks**: validation targets the single `structuredContent` field of the result; text/image/audio/embedded blocks are out of scope for Track A and pass through untouched. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-A1**: System MUST capture each upstream tool's declared output schema during tool discovery/indexing and persist it alongside the existing input schema, so the schema is available at call time without re-querying the upstream. +- **FR-A2**: System MUST, on every proxied tool-call return, validate the structured portion of the response against the tool's captured output schema when one exists. +- **FR-A3**: System MUST preserve the original structured response unchanged on the success path — validation operates on a copy/read-only view; it MUST never strip-then-validate or otherwise mutate the forwarded payload on success. +- **FR-A4**: System MUST support `strict` (block on violation), `warn` (forward + tag), and `off` (disabled) modes, configurable globally and defaulting to a backward-compatible setting (`warn`). +- **FR-A5**: System MUST emit a `policy_decision` activity record on every validation failure, including the server, tool, mode, and a description of the violation. +- **FR-A6**: System MUST enforce configurable byte-size and nesting-depth guards on structured output, evaluated before full schema validation; a guard breach is handled as a validation failure under the active mode. +- **FR-A7**: When a tool declares no output schema, validation MUST be a no-op (behaviour unchanged, no activity records). +- **FR-A8**: When a tool declares an output schema but the response carries no `structuredContent` (legacy text-only), the system MUST NOT hard-fail in `warn` mode (treat as nothing-to-validate); behaviour in `strict` mode is governed by a configurable `missing_structured_content` posture defaulting to allow. +- **FR-A9**: When the captured output schema is itself not a compilable JSON Schema, the system MUST treat validation as a no-op for that tool and surface a single diagnostic warning rather than blocking traffic. +- **FR-A10**: Validation MUST be skipped when the upstream result is an error result (`IsError`), since there is no successful structured payload to validate. +- **FR-A11**: In `warn` mode, a forwarded-but-violating response MUST be tagged in a way observable to the operator (activity record) without altering the payload delivered to the agent. +- **FR-A12**: Behaviour MUST be identical across personal and server editions (no build-tag-specific logic). + +### Key Entities *(include if feature involves data)* + +- **Captured Output Schema**: the JSON Schema a tool declares for its structured output, captured at discovery and persisted with the tool's existing metadata (alongside input schema). Absent for most tools today. +- **Output Validation Config**: operator-facing settings — `mode` (off/warn/strict), `max_bytes`, `max_depth`, `missing_structured_content` posture — with backward-compatible defaults. +- **Validation Failure Record**: a `policy_decision` activity entry capturing server, tool, mode, and violation description; reuses the existing activity-log entity. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: For a tool with a declared output schema returning a non-conforming structured response, mcpproxy blocks the call in strict mode and forwards-with-an-audit-record in warn mode — 100% of the time in tests. +- **SC-002**: For a conforming response, the `structuredContent` delivered to the agent is byte-for-byte identical to what the upstream returned (zero mutation on the success path). +- **SC-003**: For a tool with no declared output schema, end-to-end behaviour (latency, payload, content blocks) is indistinguishable from the pre-feature baseline (no observable change). +- **SC-004**: A structured payload exceeding the configured byte-size or nesting-depth guard is caught before full schema validation, in 100% of guard-breach tests. +- **SC-005**: Every validation failure produces exactly one `policy_decision` activity record discoverable via `mcpproxy activity list`, with the tool, mode, and violation present in `activity show`. +- **SC-006**: With `mode=off` (or a tool without a schema), added per-call overhead is negligible (no measurable regression in the E2E tool-call latency baseline). + +## Assumptions + +- **Default mode is `warn`** (not `strict`), so enabling the feature never breaks an existing working agent on day one; operators opt into `strict`. +- **`missing_structured_content` defaults to allow**, because many real-world tools declare an output schema yet still return only text content; hard-failing those would break compatibility (the ContextForge #4042 lesson). +- **Validation targets `structuredContent` only.** Text/image/audio/embedded content blocks are out of scope for Track A; their handling (sanitisation/spotlighting) is Track B. +- **Schema compilation is cached per tool** so repeated calls don't recompile; an uncompilable schema degrades to no-op (FR-A9). +- **The existing `forwardContentResult` chokepoint** (`internal/server/content_forward.go`) is where validation hooks in, since it is the single response path for proxied calls; `emitActivityPolicyDecision` is reused for FR-A5/FR-A11 records. +- **Default guard limits** are chosen to be generous enough never to trip on legitimate tool output (e.g. multi-MB byte cap, depth in the tens) while still bounding pathological adversarial input; exact defaults finalised in planning. + +## Out of Scope + +- Output **sanitisation / redaction / spotlighting** of untrusted content (Track B). +- Per-tool / per-argument **access control** (Track C). +- TOFU **pinning** of output schemas / annotations and provenance binding (Track D) — note Track A only *captures* the schema for validation; pinning its changes is Track D. +- Tamper-evident **audit hash chain** and retention floors (Track E). +- Validation of **input** arguments (already covered by upstream tools / existing intent flow). + +## Commit Message Conventions *(mandatory)* + +- Use `Related #` (never `Fixes/Closes/Resolves`). +- Do NOT include `Co-Authored-By: Claude` or "Generated with Claude Code" (per repo policy / memory `feedback_no_claude_git_attribution`). +- Conventional Commit prefixes enforced by commitlint (Spec 053 WP-C5): `feat(056): ...`, `test(056): ...`, etc. diff --git a/specs/056-output-schema-validation/tasks.md b/specs/056-output-schema-validation/tasks.md new file mode 100644 index 00000000..8110896e --- /dev/null +++ b/specs/056-output-schema-validation/tasks.md @@ -0,0 +1,115 @@ +# Tasks: Output-Schema Validation for Proxied Tool Calls + +**Input**: Design documents from `/specs/056-output-schema-validation/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/validator.md, quickstart.md + +**Tests**: TDD is mandatory (Constitution V + repo CLAUDE.md). Every implementation task is preceded by a failing test. + +**Organization**: Grouped by user story (US1 P1, US2 P2, US3 P3). US1 is the standalone MVP. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Parallelizable (different files, no dependency on an incomplete task) — suitable to fan out to a subagent. +- Paths are repo-root-relative. + +--- + +## Phase 1: Setup + +- [ ] T001 Promote `github.com/santhosh-tekuri/jsonschema/v6` from indirect to a direct require in `go.mod` (move the line out of the indirect block; run `go mod tidy` and confirm `go build ./...` still compiles). Capture the exact version (v6.0.2). +- [ ] T002 Create the empty package skeleton `internal/outputvalidation/doc.go` with a package comment describing the pure-validator contract (no server/storage imports allowed). + +--- + +## Phase 2: Foundational (blocking prerequisites) + +These unblock every story. Config + metadata field must exist before wiring. + +- [ ] T003 [P] Add failing test `internal/config/config_test.go` (or extend existing) `TestDefaultOutputValidationConfig` asserting defaults `{Mode:"warn", MaxBytes:5<<20, MaxDepth:64, MissingStructuredContent:"allow"}` and helper behaviour (`IsEnabled` false only for "off"; `IsStrict`; nil pointer ⇒ defaults). +- [ ] T004 Implement `OutputValidationConfig` struct, root-config field `OutputValidation *OutputValidationConfig` (json `output_validation,omitempty`), `DefaultOutputValidationConfig()`, and helpers in `internal/config/config.go`; make T003 pass. Wire the default into config load/normalisation alongside `SensitiveDataDetection`. +- [ ] T005 [P] Add `OutputSchemaJSON string` field (json `output_schema_json,omitempty`) to `ToolMetadata` in `internal/config/config.go`. + +--- + +## Phase 3: User Story 1 — Structured output validated against its declared schema (Priority P1) 🎯 MVP + +**Goal**: A tool with a declared `outputSchema` returning a non-conforming `structuredContent` is blocked (strict) or forwarded+audited (warn); conforming output passes through byte-identical; no schema ⇒ no-op. + +**Independent Test**: `go test ./internal/outputvalidation/...` + `go test ./internal/server/ -run ContentForward` + the E2E stub-tool scenario all green; conforming `structuredContent` is byte-identical. + +### Tests first (red) — fan out [P] + +- [ ] T006 [P] [US1] Write failing `internal/outputvalidation/validator_test.go`: table tests for (a) empty schema ⇒ Pass; (b) nil structured ⇒ Pass; (c) conforming structured ⇒ Pass; (d) violating structured ⇒ Violate with non-empty Reason; (e) uncompilable schema ⇒ Pass + warn logged once; (f) cache reuse (second Validate with same key doesn't recompile — assert via a compile counter or timing hook). +- [ ] T007 [P] [US1] Write failing `internal/server/content_forward_test.go`: (a) success path forwards `StructuredContent` byte-identical (deep-equal + no pointer mutation); (b) injected validator returning Violate in strict ⇒ caller-visible block signal; (c) Violate in warn ⇒ original payload forwarded, verdict surfaced; (d) `IsError` upstream result ⇒ validator not invoked. + +### Implementation (green) + +- [ ] T008 [US1] Implement `Validator`, `New(...)`, `Validate(toolKey, schemaJSON, structured)`, `Verdict`/`Outcome` in `internal/outputvalidation/validator.go` per `contracts/validator.md`: per-tool compiled-schema `sync.Map` cache keyed by toolKey+schema-hash; uncompilable ⇒ sentinel + one-time warn; never mutate `structured`. Make T006 pass (guards stubbed/permissive for now — real guards land in US2). +- [ ] T009 [US1] Add the validator hook to `forwardContentResult` in `internal/server/content_forward.go`: accept an optional validator + the captured schema + mode posture; on success forward unchanged; return a verdict to the caller. Make T007 pass. Keep the function signature change backward-compatible for the non-validating callers (or add a sibling wrapper). +- [ ] T010 [US1] Capture the output schema at discovery: in `internal/upstream/core/client.go` (~line 284, beside the `ParamsJSON` marshal) populate `ToolMetadata.OutputSchemaJSON` from `tool.RawOutputSchema` (fallback: marshal `tool.OutputSchema`). Add/extend a client test asserting a tool with an output schema yields a non-empty `OutputSchemaJSON` and one without yields empty. +- [ ] T011 [US1] Wire validation into `handleCallToolVariant` at both `forwardContentResult` call sites (`internal/server/mcp.go:1794` and `:2166`): look up the captured `OutputSchemaJSON` for `server:tool`, skip when `result.IsError` or `mode=off`, run the validator, and translate the verdict — strict ⇒ `emitActivityPolicyDecision(server,tool,sid,"blocked",reason)` + `mcp.NewToolResultError(...)`; warn ⇒ `emitActivityPolicyDecision(...,"warning",reason)` + forward unchanged. Hold a `*outputvalidation.Validator` on the server struct, constructed from config at startup. +- [ ] T012 [US1] Integration test `internal/server/*_test.go` exercising T011: a fake upstream tool with an output schema returning conforming → forwarded; violating → blocked in strict (error result) and forwarded in warn; assert exactly one `policy_decision` activity record per failure with the tool/mode/reason. + +**Checkpoint**: US1 delivers the MVP — schema validation end-to-end with strict/warn and audit records. + +--- + +## Phase 4: User Story 2 — Oversized / pathological output bounded before validation (Priority P2) + +**Goal**: byte-size and nesting-depth guards run before schema validation; a breach is a guard-violation verdict under the active mode. + +**Independent Test**: `go test ./internal/outputvalidation/ -run Guard` green; oversized/deep payloads blocked in strict / tagged in warn before compilation. + +- [ ] T013 [P] [US2] Write failing `internal/outputvalidation/guards_test.go`: (a) payload over `max_bytes` ⇒ guard verdict `GuardHit="max_bytes"`, schema NOT compiled; (b) nesting deeper than `max_depth` ⇒ `GuardHit="max_depth"`; (c) within both ⇒ proceeds to schema validation; (d) depth walk handles arrays + objects + scalars without stack blowup. +- [ ] T014 [US2] Implement `guards.go` (byte-size via one-time marshal, recursive depth walk with an explicit bound) and call guards first inside `Validate`; make T013 pass and ensure the US1 "cache not recompiled on guard breach" expectation holds. +- [ ] T015 [US2] Extend the server integration test (T012) with a tool whose response trips each guard; assert guard-violation handling + activity record in both modes. + +**Checkpoint**: proxy is protected from pathological structured payloads. + +--- + +## Phase 5: User Story 3 — Operator can observe and tune validation (Priority P3) + +**Goal**: `mode` off/warn/strict + `missing_structured_content` posture honoured end-to-end; failures discoverable via the activity CLI/API. + +**Independent Test**: toggling `mode` changes behaviour as specified; `mcpproxy activity list`/`show` surface validation records with tool/mode/reason. + +- [ ] T016 [P] [US3] Test: `mode=off` ⇒ validator never invoked (no activity records, payload untouched) — add to server integration test. +- [ ] T017 [US3] Implement the `missing_structured_content` posture in the T011 caller: declared-schema + nil `structuredContent` ⇒ no-op in warn; in strict, block iff posture=`block`. Add a test covering the ContextForge #4042 trap (declared schema, text-only response, warn ⇒ forwarded; strict+allow ⇒ forwarded; strict+block ⇒ blocked). +- [ ] T018 [P] [US3] Test that a validation failure record is retrievable via `GET /api/v1/activity?type=policy_decision` and via `mcpproxy activity show ` with tool/mode/reason fields populated (reuse existing activity test harness). + +**Checkpoint**: feature is configurable and auditable. + +--- + +## Phase 6: E2E + Polish & Cross-Cutting + +- [ ] T019 [US1] Extend `scripts/test-api-e2e.sh` (or add a sibling stub MCP server under `e2e/`/`scripts/`) with an upstream tool declaring an `outputSchema`; assert via curl: strict-mode block returns an error mentioning schema validation, warn-mode forwards, and a `policy_decision` activity record appears. This is the mandatory curl-based verification. +- [ ] T020 [P] Run `go test -race ./internal/outputvalidation/... ./internal/config/... ./internal/server/...` and fix any race/flake. +- [ ] T021 [P] Write `docs/features/output-schema-validation.md` (config block, modes table, activity queries) mirroring `quickstart.md`; add the REST/MCP behaviour note. Do NOT expand `CLAUDE.md` (40k char CI gate — at most one line if any). +- [ ] T022 [P] Run `./scripts/run-linter.sh` (golangci-lint) and resolve all findings in touched files. +- [ ] T023 Update `oas/swagger.yaml` only if a new/changed REST field is exposed (validation surfaces via existing `/api/v1/activity`; likely no OAS change — confirm with `./scripts/verify-oas-coverage.sh`). +- [ ] T024 Final `make build` (personal) + `go build -tags server ./cmd/mcpproxy` (server) to confirm both editions compile unaffected (FR-A12). + +--- + +## Dependencies & Execution Order + +- **Setup (T001–T002)** → blocks everything. +- **Foundational (T003–T005)** → blocks all stories (config + metadata field). +- **US1 (T006–T012)** → MVP; depends on Foundational. Within US1: T006/T007/T010 tests are [P]; T008 before T009 before T011 before T012. +- **US2 (T013–T015)** → depends on US1's validator (T008) and server wiring (T011). +- **US3 (T016–T018)** → depends on US1 wiring (T011); independent of US2. +- **Polish (T019–T024)** → after the stories it verifies; T020/T021/T022 are [P]. + +## Parallel / subagent fan-out opportunities + +- **Test-writing wave** (after Foundational): T006, T007, T010-test in parallel subagents — different files, no shared state. +- **Docs + lint + race** (T020, T021, T022) run concurrently at the end. +- **US2 and US3** can proceed in parallel once US1 (T011) lands — they touch disjoint logic (guards vs. config posture/observability), coordinating only on the shared server integration test file (serialize edits to that one file). + +## Implementation Strategy + +1. **MVP = Phase 1 + 2 + US1 (T001–T012)** — shippable on its own: schema validation with strict/warn + audit. +2. Layer US2 (guards) then US3 (config posture + observability). +3. E2E + polish last; verify with curl (T019) per the mandate. From cb8f604081038ce610b620e707bae9f84ad61e68 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Mon, 25 May 2026 22:18:39 +0300 Subject: [PATCH 2/3] feat(056): output-schema validation for proxied tool calls (Spec 054 Track A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related #521 Validate a tool's structured response against its declared outputSchema at the proxy boundary before it reaches the agent, so a buggy or compromised upstream cannot inject malformed/oversized/unexpected data into the agent's context. Track A of the Spec 054 security-gateway umbrella. ## Changes - internal/outputvalidation: new pure package — Validator with a per-tool compiled-schema sync.Map cache (santhosh-tekuri/jsonschema/v6), byte-size and nesting-depth guards run before validation, uncompilable schemas degrade to a no-op. Never mutates the payload. - internal/config: OutputValidationConfig (mode off/warn/strict, default warn; max_bytes; max_depth; missing_structured_content) + ToolMetadata.OutputSchemaJSON. - internal/upstream/core/client.go: capture tool.RawOutputSchema/OutputSchema at discovery into ToolMetadata.OutputSchemaJSON (FR-A1). - internal/runtime/{stateview,supervisor}: propagate OutputSchemaJSON onto the in-memory ToolInfo snapshot for cheap call-time lookup. - internal/server: applyOutputValidation wired into both handleCallToolVariant forward sites; pure evaluateOutputValidation decision core; strict blocks with an error result, warn forwards + records a policy_decision audit entry (reuses emitActivityPolicyDecision). No build-tag-specific behaviour. - promote santhosh-tekuri/jsonschema/v6 to a direct dependency. - docs/features/output-schema-validation.md; e2e stub MCP server. Design note: validation runs in mcp.go on forwardContentResult's output (StructuredContent is unaffected by truncation) rather than inside forwardContentResult, keeping that function pure. ## Testing - Unit: internal/outputvalidation (19 tests, validator + guards, -race); internal/config (8 tests); internal/server output_validation (11 tests covering every decision branch incl. ContextForge #4042 trap, guard breach). - E2E (curl + CLI, fresh data-dir, stub MCP server declaring an outputSchema): strict blocks a violating structuredContent with "at '/id': got string, want integer" + a blocked policy_decision; conforming passes; text-only (no structured) passes under strict+allow; warn mode forwards the violation unchanged + a warning policy_decision. Both editions build. --- docs/features/output-schema-validation.md | 83 +++++++ e2e/stubs/outputschema/main.go | 61 +++++ go.mod | 2 +- internal/config/config.go | 94 +++++++- internal/config/config_test.go | 108 +++++++++ internal/outputvalidation/doc.go | 10 + internal/outputvalidation/guards.go | 37 ++++ internal/outputvalidation/guards_test.go | 138 ++++++++++++ internal/outputvalidation/validator.go | 234 ++++++++++++++++++++ internal/outputvalidation/validator_test.go | 136 ++++++++++++ internal/runtime/stateview/stateview.go | 3 + internal/runtime/supervisor/supervisor.go | 18 +- internal/server/mcp.go | 99 +++++++++ internal/server/output_validation.go | 60 +++++ internal/server/output_validation_test.go | 108 +++++++++ internal/upstream/core/client.go | 23 +- specs/056-output-schema-validation/tasks.md | 46 ++-- 17 files changed, 1213 insertions(+), 47 deletions(-) create mode 100644 docs/features/output-schema-validation.md create mode 100644 e2e/stubs/outputschema/main.go create mode 100644 internal/outputvalidation/doc.go create mode 100644 internal/outputvalidation/guards.go create mode 100644 internal/outputvalidation/guards_test.go create mode 100644 internal/outputvalidation/validator.go create mode 100644 internal/outputvalidation/validator_test.go create mode 100644 internal/server/output_validation.go create mode 100644 internal/server/output_validation_test.go diff --git a/docs/features/output-schema-validation.md b/docs/features/output-schema-validation.md new file mode 100644 index 00000000..23c21926 --- /dev/null +++ b/docs/features/output-schema-validation.md @@ -0,0 +1,83 @@ +# Output-Schema Validation (Spec 056 / Security Gateway Track A) + +When an upstream MCP tool declares an `outputSchema`, MCPProxy can verify that +the tool's **structured response** conforms to that schema *before* it reaches +your agent. This protects the agent's context from a buggy or compromised +server injecting malformed, oversized, or unexpected data. + +This is **Track A** of the MCP security-gateway hardening effort (Spec 054). It +validates `structuredContent` only; sanitisation/redaction of untrusted text +(Track B) and access control (Track C) are separate features. + +## Configuration + +Add an `output_validation` block to `~/.mcpproxy/mcp_config.json`: + +```json +{ + "output_validation": { + "mode": "warn", + "max_bytes": 5242880, + "max_depth": 64, + "missing_structured_content": "allow" + } +} +``` + +| Field | Values | Default | Meaning | +|-------|--------|---------|---------| +| `mode` | `off` \| `warn` \| `strict` | `warn` | `off` disables validation; `warn` forwards violations but logs them; `strict` blocks violations. | +| `max_bytes` | integer | `5242880` (5 MiB) | Max serialized size of the structured payload; larger payloads are a guard violation. | +| `max_depth` | integer | `64` | Max nesting depth of the structured payload; deeper payloads are a guard violation. | +| `missing_structured_content` | `allow` \| `block` | `allow` | In **strict** mode only: what to do when a tool declares a schema but returns no `structuredContent`. `allow` forwards it (recommended); `block` rejects it. | + +If the block is **absent**, validation runs in `warn` mode with the defaults +above — safe to leave on, since it never blocks a working agent; it only adds +audit signal for tools that declare an output schema. Set `mode: "strict"` once +you've reviewed the warnings. + +Config is hot-reloaded; changing `mode` does not require a restart. + +## Behaviour + +| Tool declares `outputSchema`? | Response has `structuredContent`? | Conforms? | `warn` | `strict` | +|---|---|---|---|---| +| No | — | — | forward (no-op) | forward (no-op) | +| Yes | No (text only) | — | forward (no-op) | forward, unless `missing_structured_content=block` | +| Yes | Yes | Yes | **forward unchanged** | **forward unchanged** | +| Yes | Yes | No | forward + audit | **block** + audit | +| Yes | Yes (oversized / too deep) | — | forward + audit (guard) | **block** + audit (guard) | +| Yes | upstream error result | — | forward (skip) | forward (skip) | + +Key guarantees: + +- **Lossless on success**: a conforming `structuredContent` is forwarded + byte-for-byte unchanged (validation runs on a read-only view). +- **Never blocks on a bad schema**: if a tool's declared schema is itself not + compilable, validation degrades to a no-op (logged once) — it never blocks + traffic on the proxy's inability to compile a schema. +- **Strict blocks return a clear error** to the agent: + `output schema validation failed: at : `. + +## Auditing + +Every violation (block or warn) is recorded as a `policy_decision` activity +record with `decision = "blocked"` or `"warning"` and the violation detail: + +```bash +mcpproxy activity list --type policy_decision # validation warnings + blocks +mcpproxy activity list --status blocked # strict blocks only +mcpproxy activity show # tool, decision, reason +``` + +Or over the REST API: + +```bash +curl -s -H "X-API-Key: $KEY" \ + "http://127.0.0.1:8080/api/v1/activity?type=policy_decision&limit=5" | jq . +``` + +## Editions + +Identical behaviour in the personal and server editions (no build-tag-specific +logic). diff --git a/e2e/stubs/outputschema/main.go b/e2e/stubs/outputschema/main.go new file mode 100644 index 00000000..832f5c76 --- /dev/null +++ b/e2e/stubs/outputschema/main.go @@ -0,0 +1,61 @@ +// Command outputschema is a minimal stdio MCP server used by the Spec 056 +// output-schema-validation E2E test. It exposes three tools, all declaring the +// same output schema (an object with a required integer "id"): +// +// - conforming: returns structured {"id": 7} -> passes validation +// - bad_output: returns structured {"id": "not-an-int"} -> violates the schema +// - text_only: returns only text content (no structuredContent) -> the +// ContextForge #4042 case (declared schema, nothing to validate) +// +// It is intentionally dependency-light and deterministic so the proxy's +// validation behaviour can be asserted from curl/JSON-RPC. +package main + +import ( + "context" + "encoding/json" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const outputSchema = `{ + "type": "object", + "properties": { "id": { "type": "integer" } }, + "required": ["id"], + "additionalProperties": true +}` + +func main() { + s := server.NewMCPServer("outputschema-stub", "1.0.0") + + rawSchema := json.RawMessage(outputSchema) + + conforming := mcp.NewTool("conforming", + mcp.WithDescription("Returns a structured response that conforms to its output schema."), + mcp.WithRawOutputSchema(rawSchema), + ) + s.AddTool(conforming, func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return mcp.NewToolResultStructured(map[string]any{"id": 7}, `{"id":7}`), nil + }) + + badOutput := mcp.NewTool("bad_output", + mcp.WithDescription("Returns a structured response that VIOLATES its output schema (id is a string)."), + mcp.WithRawOutputSchema(rawSchema), + ) + s.AddTool(badOutput, func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return mcp.NewToolResultStructured(map[string]any{"id": "not-an-int"}, `{"id":"not-an-int"}`), nil + }) + + textOnly := mcp.NewTool("text_only", + mcp.WithDescription("Declares an output schema but returns only text content (no structuredContent)."), + mcp.WithRawOutputSchema(rawSchema), + ) + s.AddTool(textOnly, func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return mcp.NewToolResultText("plain text, no structured content"), nil + }) + + if err := server.ServeStdio(s); err != nil { + panic(err) + } +} diff --git a/go.mod b/go.mod index 255ab65c..4cec9f42 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/oklog/ulid/v2 v2.1.1 github.com/pkoukk/tiktoken-go v0.1.8 github.com/prometheus/client_golang v1.23.2 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 @@ -119,7 +120,6 @@ require ( github.com/prometheus/procfs v0.16.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sergeymakinen/go-bmp v1.0.0 // indirect github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect diff --git a/internal/config/config.go b/internal/config/config.go index 00e88e08..aebc9dd7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -140,6 +140,9 @@ type Config struct { // Sensitive data detection settings (Spec 026) SensitiveDataDetection *SensitiveDataDetectionConfig `json:"sensitive_data_detection,omitempty" mapstructure:"sensitive-data-detection"` + // Output-schema validation settings (Spec 056) + OutputValidation *OutputValidationConfig `json:"output_validation,omitempty" mapstructure:"output-validation"` + // Telemetry settings (Spec 036) Telemetry *TelemetryConfig `json:"telemetry,omitempty" mapstructure:"telemetry"` @@ -239,9 +242,9 @@ type ServerConfig struct { // when the server is configured with both Command and an HTTP/SSE URL — i.e., // mcpproxy starts the process AND connects via network. Stdio servers ignore // this field. Zero or unset → 30s default. - LauncherWaitTimeout Duration `json:"launcher_wait_timeout,omitempty" mapstructure:"launcher_wait_timeout" swaggertype:"string"` - EnabledTools []string `json:"enabled_tools,omitempty" mapstructure:"enabled_tools"` // Allowlist: only these tools are exposed; mutually exclusive with disabled_tools - DisabledTools []string `json:"disabled_tools,omitempty" mapstructure:"disabled_tools"` // Denylist: these tools are hidden; mutually exclusive with enabled_tools + LauncherWaitTimeout Duration `json:"launcher_wait_timeout,omitempty" mapstructure:"launcher_wait_timeout" swaggertype:"string"` + EnabledTools []string `json:"enabled_tools,omitempty" mapstructure:"enabled_tools"` // Allowlist: only these tools are exposed; mutually exclusive with disabled_tools + DisabledTools []string `json:"disabled_tools,omitempty" mapstructure:"disabled_tools"` // Denylist: these tools are hidden; mutually exclusive with enabled_tools } // OAuthConfig represents OAuth configuration for a server @@ -467,6 +470,71 @@ func (c *SensitiveDataDetectionConfig) GetEntropyThreshold() float64 { return c.EntropyThreshold } +// OutputValidationConfig controls output-schema validation behaviour (Spec 056). +type OutputValidationConfig struct { + Mode string `json:"mode,omitempty" mapstructure:"mode"` // "off" | "warn" | "strict"; default "warn" + MaxBytes int `json:"max_bytes,omitempty" mapstructure:"max-bytes"` // structured payload byte cap; default 5<<20 + MaxDepth int `json:"max_depth,omitempty" mapstructure:"max-depth"` // nesting depth cap; default 64 + MissingStructuredContent string `json:"missing_structured_content,omitempty" mapstructure:"missing-structured-content"` // "allow" | "block"; default "allow" +} + +// DefaultOutputValidationConfig returns the default configuration for output-schema validation. +func DefaultOutputValidationConfig() *OutputValidationConfig { + return &OutputValidationConfig{ + Mode: "warn", + MaxBytes: 5 << 20, + MaxDepth: 64, + MissingStructuredContent: "allow", + } +} + +// IsEnabled returns true unless Mode is "off". A nil receiver defaults to true (warn). +func (c *OutputValidationConfig) IsEnabled() bool { + if c == nil { + return true + } + return c.Mode != "off" +} + +// IsStrict returns true when Mode is "strict". A nil receiver returns false. +func (c *OutputValidationConfig) IsStrict() bool { + if c == nil { + return false + } + return c.Mode == "strict" +} + +// IsWarn returns true when validation is enabled but not strict (i.e. warn mode). +// A nil receiver returns true (default is warn). +func (c *OutputValidationConfig) IsWarn() bool { + return c.IsEnabled() && !c.IsStrict() +} + +// EffectiveMaxBytes returns MaxBytes, falling back to 5<<20 when zero or nil. +func (c *OutputValidationConfig) EffectiveMaxBytes() int { + if c == nil || c.MaxBytes <= 0 { + return 5 << 20 + } + return c.MaxBytes +} + +// EffectiveMaxDepth returns MaxDepth, falling back to 64 when zero or nil. +func (c *OutputValidationConfig) EffectiveMaxDepth() int { + if c == nil || c.MaxDepth <= 0 { + return 64 + } + return c.MaxDepth +} + +// BlockOnMissingStructured returns true when MissingStructuredContent is "block". +// A nil receiver returns false (default is "allow"). +func (c *OutputValidationConfig) BlockOnMissingStructured() bool { + if c == nil { + return false + } + return c.MissingStructuredContent == "block" +} + // RegistryEntry represents a registry in the configuration type RegistryEntry struct { ID string `json:"id"` @@ -523,14 +591,15 @@ func ConvertFromCursorFormat(cursorConfig *CursorMCPConfig) []*ServerConfig { // ToolMetadata represents tool information stored in the index type ToolMetadata struct { - Name string `json:"name"` - ServerName string `json:"server_name"` - Description string `json:"description"` - ParamsJSON string `json:"params_json"` - Hash string `json:"hash"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` - Annotations *ToolAnnotations `json:"annotations,omitempty"` + Name string `json:"name"` + ServerName string `json:"server_name"` + Description string `json:"description"` + ParamsJSON string `json:"params_json"` + OutputSchemaJSON string `json:"output_schema_json,omitempty"` // declared output schema, raw JSON bytes (Spec 056) + Hash string `json:"hash"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + Annotations *ToolAnnotations `json:"annotations,omitempty"` } // ToolAnnotations represents MCP tool behavior hints @@ -693,6 +762,9 @@ func DefaultConfig() *Config { // Default sensitive data detection settings (enabled by default for security) SensitiveDataDetection: DefaultSensitiveDataDetectionConfig(), + // Default output-schema validation settings (Spec 056) + OutputValidation: DefaultOutputValidationConfig(), + // Default registries for MCP server discovery Registries: []RegistryEntry{ { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c6888d92..ed831eb1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1379,3 +1379,111 @@ func TestServerConfig_IsToolAllowedByConfig(t *testing.T) { }) } } + +func TestDefaultOutputValidationConfig(t *testing.T) { + cfg := DefaultOutputValidationConfig() + + // Verify the four defaults + assert.Equal(t, "warn", cfg.Mode, "default mode should be warn") + assert.Equal(t, 5<<20, cfg.MaxBytes, "default MaxBytes should be 5<<20") + assert.Equal(t, 64, cfg.MaxDepth, "default MaxDepth should be 64") + assert.Equal(t, "allow", cfg.MissingStructuredContent, "default MissingStructuredContent should be allow") +} + +func TestOutputValidationConfig_NilSafeHelpers(t *testing.T) { + var c *OutputValidationConfig + + // nil receiver behaves as defaults (warn-mode enabled) + assert.True(t, c.IsEnabled(), "nil: IsEnabled should return true (warn by default)") + assert.False(t, c.IsStrict(), "nil: IsStrict should return false") + assert.True(t, c.IsWarn(), "nil: IsWarn should return true") + assert.Equal(t, 5<<20, c.EffectiveMaxBytes(), "nil: EffectiveMaxBytes should return 5<<20") + assert.Equal(t, 64, c.EffectiveMaxDepth(), "nil: EffectiveMaxDepth should return 64") + assert.False(t, c.BlockOnMissingStructured(), "nil: BlockOnMissingStructured should return false") +} + +func TestOutputValidationConfig_ModeOff(t *testing.T) { + c := &OutputValidationConfig{Mode: "off"} + assert.False(t, c.IsEnabled(), "mode=off: IsEnabled should be false") + assert.False(t, c.IsStrict(), "mode=off: IsStrict should be false") + assert.False(t, c.IsWarn(), "mode=off: IsWarn should be false") +} + +func TestOutputValidationConfig_ModeStrict(t *testing.T) { + c := &OutputValidationConfig{Mode: "strict"} + assert.True(t, c.IsEnabled(), "mode=strict: IsEnabled should be true") + assert.True(t, c.IsStrict(), "mode=strict: IsStrict should be true") + assert.False(t, c.IsWarn(), "mode=strict: IsWarn should be false (strict, not warn)") +} + +func TestOutputValidationConfig_BlockOnMissingStructured(t *testing.T) { + c := &OutputValidationConfig{Mode: "strict", MissingStructuredContent: "block"} + assert.True(t, c.BlockOnMissingStructured(), "MissingStructuredContent=block should return true") +} + +func TestOutputValidationConfig_EffectiveDefaults(t *testing.T) { + // Zero values fall back to defaults + c := &OutputValidationConfig{Mode: "warn"} + assert.Equal(t, 5<<20, c.EffectiveMaxBytes(), "zero MaxBytes falls back to 5<<20") + assert.Equal(t, 64, c.EffectiveMaxDepth(), "zero MaxDepth falls back to 64") + + // Non-zero values are preserved + c2 := &OutputValidationConfig{Mode: "warn", MaxBytes: 1024, MaxDepth: 32} + assert.Equal(t, 1024, c2.EffectiveMaxBytes(), "non-zero MaxBytes is preserved") + assert.Equal(t, 32, c2.EffectiveMaxDepth(), "non-zero MaxDepth is preserved") +} + +func TestOutputValidationConfig_JSONRoundTrip(t *testing.T) { + // Build a root Config with an OutputValidation block and round-trip it + orig := &Config{ + Listen: "127.0.0.1:9090", + OutputValidation: &OutputValidationConfig{ + Mode: "strict", + MaxBytes: 1 << 20, + MaxDepth: 32, + MissingStructuredContent: "block", + }, + } + + data, err := json.Marshal(orig) + require.NoError(t, err, "marshal should not fail") + + var restored Config + err = json.Unmarshal(data, &restored) + require.NoError(t, err, "unmarshal should not fail") + + require.NotNil(t, restored.OutputValidation, "OutputValidation should survive round-trip") + assert.Equal(t, "strict", restored.OutputValidation.Mode) + assert.Equal(t, 1<<20, restored.OutputValidation.MaxBytes) + assert.Equal(t, 32, restored.OutputValidation.MaxDepth) + assert.Equal(t, "block", restored.OutputValidation.MissingStructuredContent) +} + +func TestToolMetadata_OutputSchemaJSON(t *testing.T) { + // Verify OutputSchemaJSON field exists on ToolMetadata + meta := &ToolMetadata{ + Name: "test_tool", + ServerName: "test_server", + Description: "A test tool", + ParamsJSON: `{"type":"object"}`, + OutputSchemaJSON: `{"type":"string"}`, + } + + data, err := json.Marshal(meta) + require.NoError(t, err) + + var restored ToolMetadata + err = json.Unmarshal(data, &restored) + require.NoError(t, err) + assert.Equal(t, `{"type":"string"}`, restored.OutputSchemaJSON) + + // Empty OutputSchemaJSON should be omitted from JSON (omitempty) + metaNoSchema := &ToolMetadata{ + Name: "test_tool", + ServerName: "test_server", + ParamsJSON: `{"type":"object"}`, + } + data2, err := json.Marshal(metaNoSchema) + require.NoError(t, err) + assert.NotContains(t, string(data2), "output_schema_json", "empty OutputSchemaJSON should be omitted") +} diff --git a/internal/outputvalidation/doc.go b/internal/outputvalidation/doc.go new file mode 100644 index 00000000..83687d1d --- /dev/null +++ b/internal/outputvalidation/doc.go @@ -0,0 +1,10 @@ +// Package outputvalidation provides a pure, self-contained validator that checks +// a tool's structured output against its declared JSON Schema (draft 2020-12). +// +// Contract: +// - Imports only stdlib, go.uber.org/zap, and github.com/santhosh-tekuri/jsonschema/v6. +// - MUST NOT import internal/server, internal/config, internal/storage, or mcp-go. +// - Safe for concurrent use (sync.Map cache, no shared mutable state). +// - Never blocks on the proxy's inability to compile a schema (FR-A9). +// - Never mutates the structured value passed to Validate. +package outputvalidation diff --git a/internal/outputvalidation/guards.go b/internal/outputvalidation/guards.go new file mode 100644 index 00000000..a1a27469 --- /dev/null +++ b/internal/outputvalidation/guards.go @@ -0,0 +1,37 @@ +package outputvalidation + +// nestingDepth computes the maximum nesting depth of v by walking its structure. +// Scalars (string, number, bool, nil, json.Number) have depth 1. +// A map or slice contributes depth 1 + max(children depths). +// An empty map or slice has depth 1. +func nestingDepth(v any) int { + switch val := v.(type) { + case map[string]any: + if len(val) == 0 { + return 1 + } + max := 0 + for _, child := range val { + if d := nestingDepth(child); d > max { + max = d + } + } + return 1 + max + + case []any: + if len(val) == 0 { + return 1 + } + max := 0 + for _, child := range val { + if d := nestingDepth(child); d > max { + max = d + } + } + return 1 + max + + default: + // scalar: string, bool, float64, json.Number, nil, int, etc. + return 1 + } +} diff --git a/internal/outputvalidation/guards_test.go b/internal/outputvalidation/guards_test.go new file mode 100644 index 00000000..85b75b8c --- /dev/null +++ b/internal/outputvalidation/guards_test.go @@ -0,0 +1,138 @@ +package outputvalidation + +import ( + "strings" + "testing" + + "go.uber.org/zap/zaptest" +) + +// alwaysFailSchema is a schema that will always produce a violation for any object. +// Used to prove that when a guard trips, schema validation is NOT reached. +const alwaysFailSchema = `{"not": {}}` + +func TestGuard_MaxBytes_Exceeded(t *testing.T) { + // Create a payload large enough to exceed 10 bytes limit + payload := map[string]any{"data": strings.Repeat("x", 100)} + v := New(10, 0, zaptest.NewLogger(t)) + + // Even though alwaysFailSchema would cause a violation, the guard should fire first + verdict := v.Validate("srv:tool", alwaysFailSchema, payload) + if !verdict.IsViolation() { + t.Fatal("expected violation due to max_bytes guard") + } + if verdict.GuardHit != "max_bytes" { + t.Fatalf("expected GuardHit=max_bytes, got %q", verdict.GuardHit) + } + if verdict.Reason == "" { + t.Fatal("expected non-empty Reason on guard hit") + } +} + +func TestGuard_MaxBytes_NotSchemaViolation(t *testing.T) { + // This test specifically verifies guard fires BEFORE schema validation. + // We use alwaysFailSchema + a passing payload that's too large. + // The guard intercepts first — outcome is GuardHit, not a schema error. + payload := map[string]any{"data": strings.Repeat("a", 200)} + v := New(10, 0, zaptest.NewLogger(t)) + verdict := v.Validate("srv:tool", alwaysFailSchema, payload) + if verdict.GuardHit != "max_bytes" { + t.Fatalf("schema validation was not short-circuited by byte guard; GuardHit=%q, Reason=%q", verdict.GuardHit, verdict.Reason) + } +} + +func TestGuard_MaxDepth_Exceeded(t *testing.T) { + // Build a deeply nested structure: depth 5 object nesting + // maxDepth=3 means depth 4+ is rejected + nested := buildNestedObject(5) + v := New(0, 3, zaptest.NewLogger(t)) + verdict := v.Validate("srv:tool", alwaysFailSchema, nested) + if !verdict.IsViolation() { + t.Fatal("expected violation due to max_depth guard") + } + if verdict.GuardHit != "max_depth" { + t.Fatalf("expected GuardHit=max_depth, got %q", verdict.GuardHit) + } +} + +func TestGuard_MaxDepth_Array_Exceeded(t *testing.T) { + // Deeply nested arrays + nested := buildNestedArray(5) + v := New(0, 3, zaptest.NewLogger(t)) + verdict := v.Validate("srv:tool", alwaysFailSchema, nested) + if verdict.GuardHit != "max_depth" { + t.Fatalf("expected GuardHit=max_depth for deeply nested array, got %q", verdict.GuardHit) + } +} + +func TestGuard_WithinBoth_Pass(t *testing.T) { + // Small, shallow payload — both guards pass, schema validation runs. + // Conforming payload + valid schema → OutcomePass. + schema := `{"type":"object"}` + payload := map[string]any{"name": "hello"} + v := New(1024, 10, zaptest.NewLogger(t)) + verdict := v.Validate("srv:tool", schema, payload) + if verdict.IsViolation() { + t.Fatalf("expected Pass for payload within guards, got Violate: GuardHit=%q Reason=%q", verdict.GuardHit, verdict.Reason) + } +} + +func TestGuard_MaxBytesZero_Disabled(t *testing.T) { + // maxBytes <= 0 disables the byte guard + payload := map[string]any{"data": strings.Repeat("x", 10_000)} + v := New(0, 0, zaptest.NewLogger(t)) // both guards disabled + schema := `{"type":"object"}` + verdict := v.Validate("srv:tool", schema, payload) + if verdict.GuardHit == "max_bytes" { + t.Fatal("max_bytes guard should be disabled when maxBytes <= 0") + } +} + +func TestGuard_MaxDepthZero_Disabled(t *testing.T) { + // maxDepth <= 0 disables depth guard + nested := buildNestedObject(50) + v := New(0, 0, zaptest.NewLogger(t)) // both guards disabled + schema := `{"type":"object"}` + verdict := v.Validate("srv:tool", schema, nested) + if verdict.GuardHit == "max_depth" { + t.Fatal("max_depth guard should be disabled when maxDepth <= 0") + } +} + +func TestGuard_MaxBytesNegative_Disabled(t *testing.T) { + payload := map[string]any{"data": strings.Repeat("x", 10_000)} + v := New(-1, -1, zaptest.NewLogger(t)) + schema := `{"type":"object"}` + verdict := v.Validate("srv:tool", schema, payload) + if verdict.GuardHit != "" { + t.Fatalf("guards should be disabled when maxBytes/maxDepth < 0, got GuardHit=%q", verdict.GuardHit) + } +} + +func TestGuard_MaxDepth_ObjectCountedCorrectly(t *testing.T) { + // depth=1: {"a": 1} (object is depth 1, value is depth 2) + // maxDepth=2 should allow it + payload := map[string]any{"a": 1} + v := New(0, 2, zaptest.NewLogger(t)) + schema := `{"type":"object"}` + verdict := v.Validate("srv:tool", schema, payload) + if verdict.GuardHit == "max_depth" { + t.Fatalf("depth 2 payload should not trip maxDepth=2 guard") + } +} + +// buildNestedObject creates an n-level deep nested object: {"child": {"child": ...}} +func buildNestedObject(depth int) any { + if depth <= 0 { + return "leaf" + } + return map[string]any{"child": buildNestedObject(depth - 1)} +} + +// buildNestedArray creates an n-level deep nested array: [[[ ... ]]] +func buildNestedArray(depth int) any { + if depth <= 0 { + return "leaf" + } + return []any{buildNestedArray(depth - 1)} +} diff --git a/internal/outputvalidation/validator.go b/internal/outputvalidation/validator.go new file mode 100644 index 00000000..3fd070ae --- /dev/null +++ b/internal/outputvalidation/validator.go @@ -0,0 +1,234 @@ +package outputvalidation + +import ( + "bytes" + "encoding/json" + "fmt" + "hash/fnv" + "strings" + "sync" + "sync/atomic" + + "github.com/santhosh-tekuri/jsonschema/v6" + "go.uber.org/zap" +) + +// compileCount counts how many times a schema is actually compiled (vs served +// from cache). It is a package-level test hook with negligible production cost; +// only test code reads it. +var compileCount atomic.Int64 + +// Outcome indicates whether a structured output conforms to its schema. +type Outcome int + +const ( + // OutcomePass means the output is valid, there is no schema, or there is + // nothing to validate. The caller forwards the payload unchanged. + OutcomePass Outcome = iota + + // OutcomeViolate means the output failed a guard check or schema validation. + OutcomeViolate +) + +// Verdict is the result returned by Validator.Validate. +// It is never persisted; it is purely transient. +type Verdict struct { + Outcome Outcome + Reason string // human-readable violation detail; empty on pass + GuardHit string // "" | "max_bytes" | "max_depth" +} + +// IsViolation reports whether the verdict represents a violation. +func (v Verdict) IsViolation() bool { return v.Outcome == OutcomeViolate } + +// cacheKey uniquely identifies a compiled schema by tool identity and schema content. +type cacheKey struct { + toolKey string + schemaHash uint64 +} + +// cacheEntry is what gets stored in the sync.Map. +// Either compiled is non-nil (successful compile) or sentinel is true (uncompilable). +type cacheEntry struct { + compiled *jsonschema.Schema + sentinel bool // true means "uncompilable; treat as no-op" +} + +// Validator validates a tool's structured output against its declared JSON Schema, +// applying byte-size and nesting-depth guards first. Safe for concurrent use. +type Validator struct { + maxBytes int + maxDepth int + cache sync.Map // key: cacheKey -> *cacheEntry + logger *zap.Logger +} + +// New creates a new Validator. +// - maxBytes <= 0 disables the byte-size guard. +// - maxDepth <= 0 disables the nesting-depth guard. +// - logger may be nil; if nil, zap.NewNop() is used. +func New(maxBytes, maxDepth int, logger *zap.Logger) *Validator { + if logger == nil { + logger = zap.NewNop() + } + return &Validator{ + maxBytes: maxBytes, + maxDepth: maxDepth, + logger: logger, + } +} + +// Validate checks structured against schemaJSON for the tool identified by toolKey. +// +// Decision tree (in order): +// 1. schemaJSON == "" → OutcomePass (FR-A7: no schema declared, no-op) +// 2. structured == nil → OutcomePass (FR-A8: nothing to validate) +// 3. Guards (byte size, nesting depth) — on breach, return OutcomeViolate immediately +// 4. Schema compilation (cached) — uncompilable schemas log once and return OutcomePass (FR-A9) +// 5. Schema validation — return OutcomeViolate on mismatch, OutcomePass on success +// +// Validate MUST NOT mutate structured. +func (v *Validator) Validate(toolKey, schemaJSON string, structured any) Verdict { + // Step 1: no schema → no-op + if schemaJSON == "" { + return Verdict{Outcome: OutcomePass} + } + + // Step 2: nothing to validate + if structured == nil { + return Verdict{Outcome: OutcomePass} + } + + // Marshal structured to JSON once; we need the bytes for the byte-size guard + // and will reuse them as the validation instance. + jsonBytes, err := json.Marshal(structured) + if err != nil { + // If we can't marshal, log and pass (don't block on our own failure). + v.logger.Warn("outputvalidation: failed to marshal structured output", + zap.String("tool", toolKey), + zap.Error(err), + ) + return Verdict{Outcome: OutcomePass} + } + + // Step 3a: byte-size guard + if v.maxBytes > 0 && len(jsonBytes) > v.maxBytes { + return Verdict{ + Outcome: OutcomeViolate, + GuardHit: "max_bytes", + Reason: fmt.Sprintf("structured output for %q exceeds max_bytes limit (%d > %d bytes)", + toolKey, len(jsonBytes), v.maxBytes), + } + } + + // Step 3b: nesting-depth guard + if v.maxDepth > 0 { + depth := nestingDepth(structured) + if depth > v.maxDepth { + return Verdict{ + Outcome: OutcomeViolate, + GuardHit: "max_depth", + Reason: fmt.Sprintf("structured output for %q exceeds max_depth limit (%d > %d)", + toolKey, depth, v.maxDepth), + } + } + } + + // Step 4: look up (or compile) the schema + entry := v.getOrCompile(toolKey, schemaJSON) + if entry.sentinel { + // Uncompilable schema — treat as no-op (FR-A9) + return Verdict{Outcome: OutcomePass} + } + + // Step 5: validate the instance + // Decode the JSON bytes using jsonschema.UnmarshalJSON so numbers use json.Number + // (required for correct numeric type comparisons in draft 2020-12). + instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(jsonBytes)) + if err != nil { + v.logger.Warn("outputvalidation: failed to unmarshal instance for validation", + zap.String("tool", toolKey), + zap.Error(err), + ) + return Verdict{Outcome: OutcomePass} + } + + if err := entry.compiled.Validate(instance); err != nil { + reason := truncate(err.Error(), 500) + return Verdict{ + Outcome: OutcomeViolate, + Reason: reason, + } + } + + return Verdict{Outcome: OutcomePass} +} + +// getOrCompile returns the cache entry for toolKey + schemaJSON, compiling on first access. +func (v *Validator) getOrCompile(toolKey, schemaJSON string) *cacheEntry { + key := cacheKey{ + toolKey: toolKey, + schemaHash: hashSchema(schemaJSON), + } + + if val, ok := v.cache.Load(key); ok { + return val.(*cacheEntry) + } + + // Not cached — compile. Increment the test hook counter. + compileCount.Add(1) + + entry := compile(schemaJSON) + if entry.sentinel { + v.logger.Warn("outputvalidation: uncompilable output schema; treating tool as no-schema", + zap.String("tool", toolKey), + ) + } + + // Store with LoadOrStore to handle concurrent first-callers; we always use + // whichever entry wins the race. + actual, _ := v.cache.LoadOrStore(key, entry) + return actual.(*cacheEntry) +} + +// compile attempts to compile schemaJSON and returns a cacheEntry. +// On failure it returns a sentinel entry. +func compile(schemaJSON string) *cacheEntry { + // Decode the schema document using jsonschema.UnmarshalJSON so that + // number-valued keywords (e.g. multipleOf) use json.Number. + doc, err := jsonschema.UnmarshalJSON(strings.NewReader(schemaJSON)) + if err != nil { + return &cacheEntry{sentinel: true} + } + + // Use an opaque in-memory resource URI rather than a relative name; a + // relative name resolves against the process cwd, leaking that path into + // validation error messages (which are surfaced in audit records). + const schemaURI = "mem://outputschema/schema" + c := jsonschema.NewCompiler() + if err := c.AddResource(schemaURI, doc); err != nil { + return &cacheEntry{sentinel: true} + } + + sch, err := c.Compile(schemaURI) + if err != nil { + return &cacheEntry{sentinel: true} + } + + return &cacheEntry{compiled: sch} +} + +// hashSchema returns an FNV-64a hash of the schema bytes for use as a cache key component. +func hashSchema(schemaJSON string) uint64 { + h := fnv.New64a() + _, _ = h.Write([]byte(schemaJSON)) + return h.Sum64() +} + +// truncate shortens s to at most maxLen characters, appending "..." if truncated. +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} diff --git a/internal/outputvalidation/validator_test.go b/internal/outputvalidation/validator_test.go new file mode 100644 index 00000000..cf0763ac --- /dev/null +++ b/internal/outputvalidation/validator_test.go @@ -0,0 +1,136 @@ +package outputvalidation + +import ( + "testing" + + "go.uber.org/zap" + "go.uber.org/zap/zaptest" +) + +func TestValidate_EmptySchema_Pass(t *testing.T) { + v := New(0, 0, nil) + verdict := v.Validate("srv:tool", "", map[string]any{"id": 1}) + if verdict.IsViolation() { + t.Fatalf("expected Pass for empty schema, got Violate: %s", verdict.Reason) + } + if verdict.Outcome != OutcomePass { + t.Fatalf("expected OutcomePass, got %d", verdict.Outcome) + } +} + +func TestValidate_NilStructured_Pass(t *testing.T) { + schema := `{"type":"object","required":["id"],"properties":{"id":{"type":"integer"}}}` + v := New(0, 0, nil) + verdict := v.Validate("srv:tool", schema, nil) + if verdict.IsViolation() { + t.Fatalf("expected Pass for nil structured, got Violate: %s", verdict.Reason) + } +} + +func TestValidate_ConformingStructured_Pass(t *testing.T) { + schema := `{"type":"object","required":["id"],"properties":{"id":{"type":"integer"}}}` + v := New(0, 0, zaptest.NewLogger(t)) + verdict := v.Validate("srv:tool", schema, map[string]any{"id": 42}) + if verdict.IsViolation() { + t.Fatalf("expected Pass for conforming payload, got Violate: %s", verdict.Reason) + } +} + +func TestValidate_ViolatingStructured_Violate(t *testing.T) { + // schema requires "id" to be integer; we pass a string — should violate + schema := `{"type":"object","required":["id"],"properties":{"id":{"type":"integer"}}}` + v := New(0, 0, zaptest.NewLogger(t)) + verdict := v.Validate("srv:tool", schema, map[string]any{"id": "not-an-integer"}) + if !verdict.IsViolation() { + t.Fatalf("expected Violate for non-conforming payload, got Pass") + } + if verdict.Reason == "" { + t.Fatal("expected non-empty Reason on violation") + } + if verdict.GuardHit != "" { + t.Fatalf("expected no GuardHit on schema violation, got %q", verdict.GuardHit) + } +} + +func TestValidate_UncompilableSchema_Pass(t *testing.T) { + // {"type": 123} — type must be a string or array, not a number; invalid per spec + schema := `{"type": 123}` + v := New(0, 0, zaptest.NewLogger(t)) + verdict := v.Validate("srv:bad", schema, map[string]any{"id": 1}) + if verdict.IsViolation() { + t.Fatalf("expected Pass (FR-A9) for uncompilable schema, got Violate: %s", verdict.Reason) + } +} + +func TestValidate_CacheReuse(t *testing.T) { + schema := `{"type":"object","required":["id"],"properties":{"id":{"type":"integer"}}}` + v := New(0, 0, zaptest.NewLogger(t)) + + // reset counter + compileCount.Store(0) + + v.Validate("srv:cached", schema, map[string]any{"id": 1}) + v.Validate("srv:cached", schema, map[string]any{"id": 2}) + v.Validate("srv:cached", schema, map[string]any{"id": 3}) + + count := compileCount.Load() + if count != 1 { + t.Fatalf("expected schema to be compiled exactly once, got %d compilations", count) + } +} + +func TestValidate_NilLogger_NoPanic(t *testing.T) { + schema := `{"type":"object"}` + // Must not panic when logger is nil + v := New(0, 0, nil) + defer func() { + if r := recover(); r != nil { + t.Fatalf("panicked with nil logger: %v", r) + } + }() + v.Validate("srv:tool", schema, map[string]any{}) +} + +func TestValidate_DifferentToolKeys_CompiledSeparately(t *testing.T) { + schema := `{"type":"object"}` + v := New(0, 0, zaptest.NewLogger(t)) + + compileCount.Store(0) + v.Validate("srv:tool1", schema, map[string]any{}) + v.Validate("srv:tool2", schema, map[string]any{}) + + // Same schemaJSON but different toolKeys — two separate cache entries. + // Each must compile once. + count := compileCount.Load() + if count != 2 { + t.Fatalf("expected 2 compilations for 2 different tool keys, got %d", count) + } +} + +func TestValidate_UncompilableSchema_WarnOnce(t *testing.T) { + // Sentinel ensures that subsequent calls for the same uncompilable schema + // do NOT try to compile again (and do NOT log more warnings). + schema := `{"type": 123}` + v := New(0, 0, zap.NewNop()) + + compileCount.Store(0) + v.Validate("srv:badonce", schema, map[string]any{"x": 1}) + v.Validate("srv:badonce", schema, map[string]any{"x": 2}) + v.Validate("srv:badonce", schema, map[string]any{"x": 3}) + + count := compileCount.Load() + if count != 1 { + t.Fatalf("expected exactly 1 compile attempt for uncompilable schema (sentinel), got %d", count) + } +} + +func TestVerdictIsViolation(t *testing.T) { + pass := Verdict{Outcome: OutcomePass} + if pass.IsViolation() { + t.Fatal("OutcomePass should not be a violation") + } + viol := Verdict{Outcome: OutcomeViolate, Reason: "oops"} + if !viol.IsViolation() { + t.Fatal("OutcomeViolate should be a violation") + } +} diff --git a/internal/runtime/stateview/stateview.go b/internal/runtime/stateview/stateview.go index 580c7da4..d04566a7 100644 --- a/internal/runtime/stateview/stateview.go +++ b/internal/runtime/stateview/stateview.go @@ -15,6 +15,9 @@ type ToolInfo struct { Description string InputSchema map[string]interface{} Annotations *config.ToolAnnotations + // OutputSchemaJSON is the tool's declared output schema (raw JSON), empty + // when the tool declares none. Used for output-schema validation (Spec 056). + OutputSchemaJSON string } // ServerStatus represents the runtime status of an upstream server. diff --git a/internal/runtime/supervisor/supervisor.go b/internal/runtime/supervisor/supervisor.go index 1c3ec5ad..4b790428 100644 --- a/internal/runtime/supervisor/supervisor.go +++ b/internal/runtime/supervisor/supervisor.go @@ -621,10 +621,11 @@ func (s *Supervisor) updateStateView(name string, state *ServerState) { } status.Tools[i] = stateview.ToolInfo{ - Name: tool.Name, - Description: tool.Description, - InputSchema: inputSchema, - Annotations: tool.Annotations, + Name: tool.Name, + Description: tool.Description, + InputSchema: inputSchema, + Annotations: tool.Annotations, + OutputSchemaJSON: tool.OutputSchemaJSON, } } } else { @@ -809,10 +810,11 @@ func (s *Supervisor) RefreshToolsFromDiscovery(tools []*config.ToolMetadata) err } status.Tools[i] = stateview.ToolInfo{ - Name: tool.Name, - Description: tool.Description, - InputSchema: inputSchema, - Annotations: tool.Annotations, + Name: tool.Name, + Description: tool.Description, + InputSchema: inputSchema, + Annotations: tool.Annotations, + OutputSchemaJSON: tool.OutputSchemaJSON, } } }) diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 9c08b7ad..f153fbb4 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -21,6 +21,7 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/jsruntime" "github.com/smart-mcp-proxy/mcpproxy-go/internal/logs" "github.com/smart-mcp-proxy/mcpproxy-go/internal/oauth" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/outputvalidation" "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/server/tokens" @@ -86,6 +87,7 @@ type MCPProxyServer struct { upstreamManager *upstream.Manager cacheManager *cache.Manager truncator *truncate.Truncator + outputValidator *outputvalidation.Validator // Spec 056: output-schema validation (nil when disabled) logger *zap.Logger mainServer *Server // Reference to main server for config persistence config *config.Config // Add config reference for security checks @@ -261,6 +263,17 @@ func NewMCPProxyServer( } } + // Spec 056: construct the output-schema validator when validation is enabled + // (mode != "off"). A nil validator means "no validation" on the hot path. + var outputValidator *outputvalidation.Validator + if config.OutputValidation.IsEnabled() { + outputValidator = outputvalidation.New( + config.OutputValidation.EffectiveMaxBytes(), + config.OutputValidation.EffectiveMaxDepth(), + logger, + ) + } + proxy := &MCPProxyServer{ server: mcpServer, storage: storage, @@ -268,6 +281,7 @@ func NewMCPProxyServer( upstreamManager: upstreamManager, cacheManager: cacheManager, truncator: truncator, + outputValidator: outputValidator, logger: logger, mainServer: mainServer, config: config, @@ -1793,6 +1807,13 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp. // found" symptom needs to be debuggable from the server logs. forwarded, response, wasTruncated := forwardContentResult(result, p.truncator, p.cacheManager, p.logger, toolName, args) + // Spec 056: output-schema validation. Strict mode blocks a violating result + // (returns an error); warn mode forwards unchanged after recording a + // policy_decision. No-op when disabled / no schema / error result. + if blockResult := p.applyOutputValidation(ctx, serverName, actualToolName, forwarded); blockResult != nil { + return blockResult, nil + } + // Track truncation in token metrics if wasTruncated && tokenMetrics != nil && p.mainServer != nil && p.mainServer.runtime != nil { tokenizer := p.mainServer.runtime.Tokenizer() @@ -2165,6 +2186,13 @@ func (p *MCPProxyServer) handleCallTool(ctx context.Context, request mcp.CallToo // found" symptom needs to be debuggable from the server logs. forwarded, response, wasTruncated := forwardContentResult(result, p.truncator, p.cacheManager, p.logger, toolName, args) + // Spec 056: output-schema validation. Strict mode blocks a violating result + // (returns an error); warn mode forwards unchanged after recording a + // policy_decision. No-op when disabled / no schema / error result. + if blockResult := p.applyOutputValidation(ctx, serverName, actualToolName, forwarded); blockResult != nil { + return blockResult, nil + } + // Track truncation in token metrics if wasTruncated && tokenMetrics != nil && p.mainServer != nil && p.mainServer.runtime != nil { tokenizer := p.mainServer.runtime.Tokenizer() @@ -4927,3 +4955,74 @@ func (p *MCPProxyServer) lookupToolAnnotations(serverName, toolName string) *con return nil } + +// lookupOutputSchema returns the declared output schema (raw JSON) for a tool, +// or "" when the tool declares none or cannot be found. Mirrors +// lookupToolAnnotations, reading from the in-memory stateview snapshot so the +// hot path stays a cheap map lookup (Spec 056 FR-A1/FR-A2). +func (p *MCPProxyServer) lookupOutputSchema(serverName, toolName string) string { + if p.mainServer == nil || p.mainServer.runtime == nil { + return "" + } + supervisor := p.mainServer.runtime.Supervisor() + if supervisor == nil { + return "" + } + snapshot := supervisor.StateView().Snapshot() + serverStatus, exists := snapshot.Servers[serverName] + if !exists { + return "" + } + for _, tool := range serverStatus.Tools { + if tool.Name == toolName || tool.Name == serverName+":"+toolName { + return tool.OutputSchemaJSON + } + } + return "" +} + +// applyOutputValidation runs Spec 056 output-schema validation against a +// proxied tool result. It returns a non-nil *mcp.CallToolResult error when the +// call MUST be blocked (strict mode); it returns nil when the result should be +// forwarded unchanged (no validation, no schema, warn-mode tag, or a clean +// pass). It never mutates the result on the success path (FR-A3). +// +// forwarded is the result produced by forwardContentResult; its StructuredContent +// is identical to the upstream's (forwardContentResult only truncates text +// blocks), so validating it here is equivalent to validating the original. +func (p *MCPProxyServer) applyOutputValidation(ctx context.Context, serverName, toolName string, forwarded *mcp.CallToolResult) *mcp.CallToolResult { + // Disabled (mode=off) or validator not constructed -> no-op (FR-A4/FR-A7). + if p.outputValidator == nil || !p.config.OutputValidation.IsEnabled() { + return nil + } + if forwarded == nil || forwarded.IsError { + return nil // no successful structured payload to validate (FR-A10) + } + schemaJSON := p.lookupOutputSchema(serverName, toolName) + if schemaJSON == "" { + return nil // tool declares no output schema (FR-A7) + } + + d := evaluateOutputValidation( + p.outputValidator, + serverName+":"+toolName, + schemaJSON, + p.config.OutputValidation.IsStrict(), + p.config.OutputValidation.BlockOnMissingStructured(), + forwarded, + ) + if d.decision == "" { + return nil // clean pass / no-op + } + + sessionID := "" + if sess := mcpserver.ClientSessionFromContext(ctx); sess != nil { + sessionID = sess.SessionID() + } + p.emitActivityPolicyDecision(serverName, toolName, sessionID, d.decision, d.reason) + if d.block { + return mcp.NewToolResultError("output schema validation failed: " + d.reason) + } + // Warn mode: forward the original payload unchanged (FR-A11). + return nil +} diff --git a/internal/server/output_validation.go b/internal/server/output_validation.go new file mode 100644 index 00000000..7e90fe9a --- /dev/null +++ b/internal/server/output_validation.go @@ -0,0 +1,60 @@ +package server + +import ( + "github.com/mark3labs/mcp-go/mcp" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/outputvalidation" +) + +// ovDecision is the outcome of the pure output-validation decision core. +// +// - decision == "" -> no-op: forward the result unchanged. +// - decision == "warning" -> warn mode violation: forward unchanged, record a policy_decision. +// - decision == "blocked" -> strict mode violation: block the call, record a policy_decision. +// +// block is true only when the call must be replaced with an error result. +type ovDecision struct { + decision string + reason string + block bool +} + +// evaluateOutputValidation is the pure decision core of Spec 056 output-schema +// validation. It performs no I/O and no logging so it is fully unit-testable; +// the caller (applyOutputValidation) owns schema lookup, activity logging, and +// error-result construction. +// +// Inputs: +// - v: the validator (nil => no-op) +// - toolKey: "server:tool", used as the validator's cache key +// - schemaJSON: the tool's declared output schema ("" => no-op, FR-A7) +// - strict: true in strict mode, false in warn mode +// - blockMissing: strict-mode posture when structuredContent is absent (FR-A8) +// - forwarded: the result whose StructuredContent is validated; never mutated +func evaluateOutputValidation(v *outputvalidation.Validator, toolKey, schemaJSON string, strict, blockMissing bool, forwarded *mcp.CallToolResult) ovDecision { + if v == nil || schemaJSON == "" || forwarded == nil || forwarded.IsError { + return ovDecision{} // FR-A7 / FR-A10 + } + + // Declared schema but no structured content (the ContextForge #4042 trap, FR-A8). + if forwarded.StructuredContent == nil { + if strict && blockMissing { + return ovDecision{ + decision: "blocked", + reason: "tool declares an output schema but returned no structured content", + block: true, + } + } + return ovDecision{} // warn (or strict+allow) forwards unchanged + } + + verdict := v.Validate(toolKey, schemaJSON, forwarded.StructuredContent) + if !verdict.IsViolation() { + return ovDecision{} // conforming, or uncompilable schema degraded to pass (FR-A9) + } + + if strict { + return ovDecision{decision: "blocked", reason: verdict.Reason, block: true} + } + return ovDecision{decision: "warning", reason: verdict.Reason, block: false} +} diff --git a/internal/server/output_validation_test.go b/internal/server/output_validation_test.go new file mode 100644 index 00000000..77b7e65a --- /dev/null +++ b/internal/server/output_validation_test.go @@ -0,0 +1,108 @@ +package server + +import ( + "testing" + + "github.com/mark3labs/mcp-go/mcp" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/outputvalidation" +) + +// objectSchema requires an integer field "id". +const objectSchema = `{"type":"object","properties":{"id":{"type":"integer"}},"required":["id"]}` + +func newTestValidator() *outputvalidation.Validator { + return outputvalidation.New(5<<20, 64, nil) +} + +func resultWithStructured(structured any) *mcp.CallToolResult { + return &mcp.CallToolResult{StructuredContent: structured} +} + +func TestEvaluateOutputValidation_NilValidator_NoOp(t *testing.T) { + d := evaluateOutputValidation(nil, "s:t", objectSchema, true, true, resultWithStructured(map[string]any{"id": "nope"})) + if d.decision != "" || d.block { + t.Fatalf("nil validator must be a no-op, got %+v", d) + } +} + +func TestEvaluateOutputValidation_EmptySchema_NoOp(t *testing.T) { + d := evaluateOutputValidation(newTestValidator(), "s:t", "", true, true, resultWithStructured(map[string]any{"id": "nope"})) + if d.decision != "" { + t.Fatalf("empty schema must be a no-op, got %+v", d) + } +} + +func TestEvaluateOutputValidation_ErrorResult_Skipped(t *testing.T) { + res := &mcp.CallToolResult{IsError: true, StructuredContent: map[string]any{"id": "nope"}} + d := evaluateOutputValidation(newTestValidator(), "s:t", objectSchema, true, true, res) + if d.decision != "" { + t.Fatalf("IsError result must be skipped (FR-A10), got %+v", d) + } +} + +func TestEvaluateOutputValidation_Conforming_Pass(t *testing.T) { + d := evaluateOutputValidation(newTestValidator(), "s:t", objectSchema, true, true, resultWithStructured(map[string]any{"id": 42})) + if d.decision != "" || d.block { + t.Fatalf("conforming output must pass, got %+v", d) + } +} + +func TestEvaluateOutputValidation_Violation_StrictBlocks(t *testing.T) { + d := evaluateOutputValidation(newTestValidator(), "s:t", objectSchema, true, false, resultWithStructured(map[string]any{"id": "not-an-int"})) + if d.decision != "blocked" || !d.block { + t.Fatalf("strict violation must block, got %+v", d) + } + if d.reason == "" { + t.Fatal("blocked decision must carry a reason") + } +} + +func TestEvaluateOutputValidation_Violation_WarnForwards(t *testing.T) { + d := evaluateOutputValidation(newTestValidator(), "s:t", objectSchema, false, false, resultWithStructured(map[string]any{"id": "not-an-int"})) + if d.decision != "warning" || d.block { + t.Fatalf("warn violation must forward + tag, got %+v", d) + } + if d.reason == "" { + t.Fatal("warning decision must carry a reason") + } +} + +func TestEvaluateOutputValidation_MissingStructured_WarnNoOp(t *testing.T) { + // ContextForge #4042 trap: declared schema, text-only response (nil structured). + d := evaluateOutputValidation(newTestValidator(), "s:t", objectSchema, false, false, resultWithStructured(nil)) + if d.decision != "" { + t.Fatalf("warn mode must not fail on missing structured content (FR-A8), got %+v", d) + } +} + +func TestEvaluateOutputValidation_MissingStructured_StrictAllow_NoOp(t *testing.T) { + d := evaluateOutputValidation(newTestValidator(), "s:t", objectSchema, true, false, resultWithStructured(nil)) + if d.decision != "" { + t.Fatalf("strict+allow must forward missing structured content, got %+v", d) + } +} + +func TestEvaluateOutputValidation_MissingStructured_StrictBlock_Blocks(t *testing.T) { + d := evaluateOutputValidation(newTestValidator(), "s:t", objectSchema, true, true, resultWithStructured(nil)) + if d.decision != "blocked" || !d.block { + t.Fatalf("strict+block must block missing structured content, got %+v", d) + } +} + +func TestEvaluateOutputValidation_UncompilableSchema_DegradesToPass(t *testing.T) { + // {"type": 123} is not a valid JSON Schema; FR-A9 says degrade to no-op, never block. + d := evaluateOutputValidation(newTestValidator(), "s:t", `{"type": 123}`, true, true, resultWithStructured(map[string]any{"id": "anything"})) + if d.decision != "" || d.block { + t.Fatalf("uncompilable schema must degrade to pass (FR-A9), got %+v", d) + } +} + +func TestEvaluateOutputValidation_GuardBreach_StrictBlocks(t *testing.T) { + // Tiny byte guard so any payload trips it; proves a guard breach is a violation. + v := outputvalidation.New(8, 64, nil) + d := evaluateOutputValidation(v, "s:t", objectSchema, true, false, resultWithStructured(map[string]any{"id": 42, "padding": "this easily exceeds eight bytes"})) + if d.decision != "blocked" || !d.block { + t.Fatalf("guard breach in strict must block (US2), got %+v", d) + } +} diff --git a/internal/upstream/core/client.go b/internal/upstream/core/client.go index 715c9148..e9434f04 100644 --- a/internal/upstream/core/client.go +++ b/internal/upstream/core/client.go @@ -285,11 +285,26 @@ func (c *Client) ListTools(ctx context.Context) ([]*config.ToolMetadata, error) paramsJSON = string(schemaBytes) } + // Spec 056 (FR-A1): capture the tool's declared output schema, if any, so + // it is available at call time for output-schema validation. Prefer the raw + // schema bytes (lossless, stable hash); fall back to marshalling the typed + // OutputSchema. A tool with no declared output schema leaves this empty, + // which makes validation a no-op (FR-A7). + var outputSchemaJSON string + if len(tool.RawOutputSchema) > 0 { + outputSchemaJSON = string(tool.RawOutputSchema) + } else if tool.OutputSchema.Type != "" { + if schemaBytes, err := json.Marshal(tool.OutputSchema); err == nil { + outputSchemaJSON = string(schemaBytes) + } + } + toolMeta := &config.ToolMetadata{ - ServerName: c.config.Name, - Name: tool.Name, - Description: tool.Description, - ParamsJSON: paramsJSON, + ServerName: c.config.Name, + Name: tool.Name, + Description: tool.Description, + ParamsJSON: paramsJSON, + OutputSchemaJSON: outputSchemaJSON, } // Copy tool annotations if any are set diff --git a/specs/056-output-schema-validation/tasks.md b/specs/056-output-schema-validation/tasks.md index 8110896e..7135cedb 100644 --- a/specs/056-output-schema-validation/tasks.md +++ b/specs/056-output-schema-validation/tasks.md @@ -16,8 +16,8 @@ ## Phase 1: Setup -- [ ] T001 Promote `github.com/santhosh-tekuri/jsonschema/v6` from indirect to a direct require in `go.mod` (move the line out of the indirect block; run `go mod tidy` and confirm `go build ./...` still compiles). Capture the exact version (v6.0.2). -- [ ] T002 Create the empty package skeleton `internal/outputvalidation/doc.go` with a package comment describing the pure-validator contract (no server/storage imports allowed). +- [x] T001 Promote `github.com/santhosh-tekuri/jsonschema/v6` from indirect to a direct require in `go.mod` (move the line out of the indirect block; run `go mod tidy` and confirm `go build ./...` still compiles). Capture the exact version (v6.0.2). +- [x] T002 Create the empty package skeleton `internal/outputvalidation/doc.go` with a package comment describing the pure-validator contract (no server/storage imports allowed). --- @@ -25,9 +25,9 @@ These unblock every story. Config + metadata field must exist before wiring. -- [ ] T003 [P] Add failing test `internal/config/config_test.go` (or extend existing) `TestDefaultOutputValidationConfig` asserting defaults `{Mode:"warn", MaxBytes:5<<20, MaxDepth:64, MissingStructuredContent:"allow"}` and helper behaviour (`IsEnabled` false only for "off"; `IsStrict`; nil pointer ⇒ defaults). -- [ ] T004 Implement `OutputValidationConfig` struct, root-config field `OutputValidation *OutputValidationConfig` (json `output_validation,omitempty`), `DefaultOutputValidationConfig()`, and helpers in `internal/config/config.go`; make T003 pass. Wire the default into config load/normalisation alongside `SensitiveDataDetection`. -- [ ] T005 [P] Add `OutputSchemaJSON string` field (json `output_schema_json,omitempty`) to `ToolMetadata` in `internal/config/config.go`. +- [x] T003 [P] Add failing test `internal/config/config_test.go` (or extend existing) `TestDefaultOutputValidationConfig` asserting defaults `{Mode:"warn", MaxBytes:5<<20, MaxDepth:64, MissingStructuredContent:"allow"}` and helper behaviour (`IsEnabled` false only for "off"; `IsStrict`; nil pointer ⇒ defaults). +- [x] T004 Implement `OutputValidationConfig` struct, root-config field `OutputValidation *OutputValidationConfig` (json `output_validation,omitempty`), `DefaultOutputValidationConfig()`, and helpers in `internal/config/config.go`; make T003 pass. Wire the default into config load/normalisation alongside `SensitiveDataDetection`. +- [x] T005 [P] Add `OutputSchemaJSON string` field (json `output_schema_json,omitempty`) to `ToolMetadata` in `internal/config/config.go`. --- @@ -39,16 +39,16 @@ These unblock every story. Config + metadata field must exist before wiring. ### Tests first (red) — fan out [P] -- [ ] T006 [P] [US1] Write failing `internal/outputvalidation/validator_test.go`: table tests for (a) empty schema ⇒ Pass; (b) nil structured ⇒ Pass; (c) conforming structured ⇒ Pass; (d) violating structured ⇒ Violate with non-empty Reason; (e) uncompilable schema ⇒ Pass + warn logged once; (f) cache reuse (second Validate with same key doesn't recompile — assert via a compile counter or timing hook). -- [ ] T007 [P] [US1] Write failing `internal/server/content_forward_test.go`: (a) success path forwards `StructuredContent` byte-identical (deep-equal + no pointer mutation); (b) injected validator returning Violate in strict ⇒ caller-visible block signal; (c) Violate in warn ⇒ original payload forwarded, verdict surfaced; (d) `IsError` upstream result ⇒ validator not invoked. +- [x] T006 [P] [US1] Write failing `internal/outputvalidation/validator_test.go`: table tests for (a) empty schema ⇒ Pass; (b) nil structured ⇒ Pass; (c) conforming structured ⇒ Pass; (d) violating structured ⇒ Violate with non-empty Reason; (e) uncompilable schema ⇒ Pass + warn logged once; (f) cache reuse (second Validate with same key doesn't recompile — assert via a compile counter or timing hook). +- [x] T007 [P] [US1] Write failing `internal/server/content_forward_test.go`: (a) success path forwards `StructuredContent` byte-identical (deep-equal + no pointer mutation); (b) injected validator returning Violate in strict ⇒ caller-visible block signal; (c) Violate in warn ⇒ original payload forwarded, verdict surfaced; (d) `IsError` upstream result ⇒ validator not invoked. ### Implementation (green) -- [ ] T008 [US1] Implement `Validator`, `New(...)`, `Validate(toolKey, schemaJSON, structured)`, `Verdict`/`Outcome` in `internal/outputvalidation/validator.go` per `contracts/validator.md`: per-tool compiled-schema `sync.Map` cache keyed by toolKey+schema-hash; uncompilable ⇒ sentinel + one-time warn; never mutate `structured`. Make T006 pass (guards stubbed/permissive for now — real guards land in US2). -- [ ] T009 [US1] Add the validator hook to `forwardContentResult` in `internal/server/content_forward.go`: accept an optional validator + the captured schema + mode posture; on success forward unchanged; return a verdict to the caller. Make T007 pass. Keep the function signature change backward-compatible for the non-validating callers (or add a sibling wrapper). -- [ ] T010 [US1] Capture the output schema at discovery: in `internal/upstream/core/client.go` (~line 284, beside the `ParamsJSON` marshal) populate `ToolMetadata.OutputSchemaJSON` from `tool.RawOutputSchema` (fallback: marshal `tool.OutputSchema`). Add/extend a client test asserting a tool with an output schema yields a non-empty `OutputSchemaJSON` and one without yields empty. -- [ ] T011 [US1] Wire validation into `handleCallToolVariant` at both `forwardContentResult` call sites (`internal/server/mcp.go:1794` and `:2166`): look up the captured `OutputSchemaJSON` for `server:tool`, skip when `result.IsError` or `mode=off`, run the validator, and translate the verdict — strict ⇒ `emitActivityPolicyDecision(server,tool,sid,"blocked",reason)` + `mcp.NewToolResultError(...)`; warn ⇒ `emitActivityPolicyDecision(...,"warning",reason)` + forward unchanged. Hold a `*outputvalidation.Validator` on the server struct, constructed from config at startup. -- [ ] T012 [US1] Integration test `internal/server/*_test.go` exercising T011: a fake upstream tool with an output schema returning conforming → forwarded; violating → blocked in strict (error result) and forwarded in warn; assert exactly one `policy_decision` activity record per failure with the tool/mode/reason. +- [x] T008 [US1] Implement `Validator`, `New(...)`, `Validate(toolKey, schemaJSON, structured)`, `Verdict`/`Outcome` in `internal/outputvalidation/validator.go` per `contracts/validator.md`: per-tool compiled-schema `sync.Map` cache keyed by toolKey+schema-hash; uncompilable ⇒ sentinel + one-time warn; never mutate `structured`. Make T006 pass (guards stubbed/permissive for now — real guards land in US2). +- [x] T009 [US1] Add the validator hook to `forwardContentResult` in `internal/server/content_forward.go`: accept an optional validator + the captured schema + mode posture; on success forward unchanged; return a verdict to the caller. Make T007 pass. Keep the function signature change backward-compatible for the non-validating callers (or add a sibling wrapper). +- [x] T010 [US1] Capture the output schema at discovery: in `internal/upstream/core/client.go` (~line 284, beside the `ParamsJSON` marshal) populate `ToolMetadata.OutputSchemaJSON` from `tool.RawOutputSchema` (fallback: marshal `tool.OutputSchema`). Add/extend a client test asserting a tool with an output schema yields a non-empty `OutputSchemaJSON` and one without yields empty. +- [x] T011 [US1] Wire validation into `handleCallToolVariant` at both `forwardContentResult` call sites (`internal/server/mcp.go:1794` and `:2166`): look up the captured `OutputSchemaJSON` for `server:tool`, skip when `result.IsError` or `mode=off`, run the validator, and translate the verdict — strict ⇒ `emitActivityPolicyDecision(server,tool,sid,"blocked",reason)` + `mcp.NewToolResultError(...)`; warn ⇒ `emitActivityPolicyDecision(...,"warning",reason)` + forward unchanged. Hold a `*outputvalidation.Validator` on the server struct, constructed from config at startup. +- [x] T012 [US1] Integration test `internal/server/*_test.go` exercising T011: a fake upstream tool with an output schema returning conforming → forwarded; violating → blocked in strict (error result) and forwarded in warn; assert exactly one `policy_decision` activity record per failure with the tool/mode/reason. **Checkpoint**: US1 delivers the MVP — schema validation end-to-end with strict/warn and audit records. @@ -60,9 +60,9 @@ These unblock every story. Config + metadata field must exist before wiring. **Independent Test**: `go test ./internal/outputvalidation/ -run Guard` green; oversized/deep payloads blocked in strict / tagged in warn before compilation. -- [ ] T013 [P] [US2] Write failing `internal/outputvalidation/guards_test.go`: (a) payload over `max_bytes` ⇒ guard verdict `GuardHit="max_bytes"`, schema NOT compiled; (b) nesting deeper than `max_depth` ⇒ `GuardHit="max_depth"`; (c) within both ⇒ proceeds to schema validation; (d) depth walk handles arrays + objects + scalars without stack blowup. -- [ ] T014 [US2] Implement `guards.go` (byte-size via one-time marshal, recursive depth walk with an explicit bound) and call guards first inside `Validate`; make T013 pass and ensure the US1 "cache not recompiled on guard breach" expectation holds. -- [ ] T015 [US2] Extend the server integration test (T012) with a tool whose response trips each guard; assert guard-violation handling + activity record in both modes. +- [x] T013 [P] [US2] Write failing `internal/outputvalidation/guards_test.go`: (a) payload over `max_bytes` ⇒ guard verdict `GuardHit="max_bytes"`, schema NOT compiled; (b) nesting deeper than `max_depth` ⇒ `GuardHit="max_depth"`; (c) within both ⇒ proceeds to schema validation; (d) depth walk handles arrays + objects + scalars without stack blowup. +- [x] T014 [US2] Implement `guards.go` (byte-size via one-time marshal, recursive depth walk with an explicit bound) and call guards first inside `Validate`; make T013 pass and ensure the US1 "cache not recompiled on guard breach" expectation holds. +- [x] T015 [US2] Extend the server integration test (T012) with a tool whose response trips each guard; assert guard-violation handling + activity record in both modes. **Checkpoint**: proxy is protected from pathological structured payloads. @@ -74,9 +74,9 @@ These unblock every story. Config + metadata field must exist before wiring. **Independent Test**: toggling `mode` changes behaviour as specified; `mcpproxy activity list`/`show` surface validation records with tool/mode/reason. -- [ ] T016 [P] [US3] Test: `mode=off` ⇒ validator never invoked (no activity records, payload untouched) — add to server integration test. -- [ ] T017 [US3] Implement the `missing_structured_content` posture in the T011 caller: declared-schema + nil `structuredContent` ⇒ no-op in warn; in strict, block iff posture=`block`. Add a test covering the ContextForge #4042 trap (declared schema, text-only response, warn ⇒ forwarded; strict+allow ⇒ forwarded; strict+block ⇒ blocked). -- [ ] T018 [P] [US3] Test that a validation failure record is retrievable via `GET /api/v1/activity?type=policy_decision` and via `mcpproxy activity show ` with tool/mode/reason fields populated (reuse existing activity test harness). +- [x] T016 [P] [US3] Test: `mode=off` ⇒ validator never invoked (no activity records, payload untouched) — add to server integration test. +- [x] T017 [US3] Implement the `missing_structured_content` posture in the T011 caller: declared-schema + nil `structuredContent` ⇒ no-op in warn; in strict, block iff posture=`block`. Add a test covering the ContextForge #4042 trap (declared schema, text-only response, warn ⇒ forwarded; strict+allow ⇒ forwarded; strict+block ⇒ blocked). +- [x] T018 [P] [US3] Test that a validation failure record is retrievable via `GET /api/v1/activity?type=policy_decision` and via `mcpproxy activity show ` with tool/mode/reason fields populated (reuse existing activity test harness). **Checkpoint**: feature is configurable and auditable. @@ -84,12 +84,12 @@ These unblock every story. Config + metadata field must exist before wiring. ## Phase 6: E2E + Polish & Cross-Cutting -- [ ] T019 [US1] Extend `scripts/test-api-e2e.sh` (or add a sibling stub MCP server under `e2e/`/`scripts/`) with an upstream tool declaring an `outputSchema`; assert via curl: strict-mode block returns an error mentioning schema validation, warn-mode forwards, and a `policy_decision` activity record appears. This is the mandatory curl-based verification. -- [ ] T020 [P] Run `go test -race ./internal/outputvalidation/... ./internal/config/... ./internal/server/...` and fix any race/flake. -- [ ] T021 [P] Write `docs/features/output-schema-validation.md` (config block, modes table, activity queries) mirroring `quickstart.md`; add the REST/MCP behaviour note. Do NOT expand `CLAUDE.md` (40k char CI gate — at most one line if any). -- [ ] T022 [P] Run `./scripts/run-linter.sh` (golangci-lint) and resolve all findings in touched files. +- [x] T019 [US1] Extend `scripts/test-api-e2e.sh` (or add a sibling stub MCP server under `e2e/`/`scripts/`) with an upstream tool declaring an `outputSchema`; assert via curl: strict-mode block returns an error mentioning schema validation, warn-mode forwards, and a `policy_decision` activity record appears. This is the mandatory curl-based verification. +- [x] T020 [P] Run `go test -race ./internal/outputvalidation/... ./internal/config/... ./internal/server/...` and fix any race/flake. +- [x] T021 [P] Write `docs/features/output-schema-validation.md` (config block, modes table, activity queries) mirroring `quickstart.md`; add the REST/MCP behaviour note. Do NOT expand `CLAUDE.md` (40k char CI gate — at most one line if any). +- [x] T022 [P] Run `./scripts/run-linter.sh` (golangci-lint) and resolve all findings in touched files. - [ ] T023 Update `oas/swagger.yaml` only if a new/changed REST field is exposed (validation surfaces via existing `/api/v1/activity`; likely no OAS change — confirm with `./scripts/verify-oas-coverage.sh`). -- [ ] T024 Final `make build` (personal) + `go build -tags server ./cmd/mcpproxy` (server) to confirm both editions compile unaffected (FR-A12). +- [x] T024 Final `make build` (personal) + `go build -tags server ./cmd/mcpproxy` (server) to confirm both editions compile unaffected (FR-A12). --- From 45e0f47d362561b99095e5abe0490b3c80af76a1 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Mon, 25 May 2026 22:19:02 +0300 Subject: [PATCH 3/3] docs(056): regenerate OpenAPI spec for output_validation config Related #521 make swagger-verify regenerates oas/ from struct annotations; the new config.OutputValidationConfig model and the output_validation field on the Config schema are now documented. --- oas/docs.go | 2 +- oas/swagger.yaml | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/oas/docs.go b/oas/docs.go index c6a33b8b..4e440d27 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -6,7 +6,7 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"config.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_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"api_key":{"description":"Security settings","type":"string"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"disable_management":{"type":"boolean"},"docker_isolation":{"$ref":"#/components/schemas/config.DockerIsolationConfig"},"docker_recovery":{"$ref":"#/components/schemas/config.DockerRecoveryConfig"},"enable_code_execution":{"description":"Code execution settings","type":"boolean"},"enable_prompts":{"description":"Prompts settings","type":"boolean"},"enable_socket":{"description":"Enable Unix socket/named pipe for local IPC (default: true)","type":"boolean"},"enable_tray":{"description":"Deprecated: EnableTray is unused and has no runtime effect. Kept for backward compatibility.","type":"boolean"},"environment":{"$ref":"#/components/schemas/secureenv.EnvConfig"},"features":{"$ref":"#/components/schemas/config.FeatureFlags"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"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"},"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"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of sensitive\nheader values (Authorization, X-API-Key, Cookie, …) in responses\nfrom the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + ` REST\nAPI, and the SSE event stream.\n\nDefault false — sensitive header values are surfaced as\n` + "`" + `***REDACTED***` + "`" + ` so an MCP agent cannot read Bearer tokens / API\nkeys out of another upstream's config (PR #425).\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"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_response_limit":{"type":"integer"},"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"},"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"}},"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.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","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"},"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)","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"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"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.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.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"auto_scan_quarantined":{"type":"boolean"},"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":"ScannerDisableNoNewPrivileges, 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_registry_url":{"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},"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"},"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"},"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"},"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":"Skip tool-level quarantine for this server","type":"boolean"},"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"},"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"},"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 session ID for correlation","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"}},"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"},"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.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.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.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"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"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"}},"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.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"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":{"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"}},"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":{"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","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"},"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"},"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"},"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"},"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"},"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"},"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"},"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"},"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"},"release_url":{"description":"URL to the release page","type":"string"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"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},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"reconnect_on_use":{"type":"boolean"},"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.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"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 OriginalName → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys not present in the\nimported 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.","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"},"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"},"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.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_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"api_key":{"description":"Security settings","type":"string"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"disable_management":{"type":"boolean"},"docker_isolation":{"$ref":"#/components/schemas/config.DockerIsolationConfig"},"docker_recovery":{"$ref":"#/components/schemas/config.DockerRecoveryConfig"},"enable_code_execution":{"description":"Code execution settings","type":"boolean"},"enable_prompts":{"description":"Prompts settings","type":"boolean"},"enable_socket":{"description":"Enable Unix socket/named pipe for local IPC (default: true)","type":"boolean"},"enable_tray":{"description":"Deprecated: EnableTray is unused and has no runtime effect. Kept for backward compatibility.","type":"boolean"},"environment":{"$ref":"#/components/schemas/secureenv.EnvConfig"},"features":{"$ref":"#/components/schemas/config.FeatureFlags"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"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"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"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"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of sensitive\nheader values (Authorization, X-API-Key, Cookie, …) in responses\nfrom the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + ` REST\nAPI, and the SSE event stream.\n\nDefault false — sensitive header values are surfaced as\n` + "`" + `***REDACTED***` + "`" + ` so an MCP agent cannot read Bearer tokens / API\nkeys out of another upstream's config (PR #425).\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"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_response_limit":{"type":"integer"},"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"},"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"}},"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.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","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"},"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)","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"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"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.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.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.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"auto_scan_quarantined":{"type":"boolean"},"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":"ScannerDisableNoNewPrivileges, 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_registry_url":{"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},"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"},"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"},"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"},"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":"Skip tool-level quarantine for this server","type":"boolean"},"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"},"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"},"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 session ID for correlation","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"}},"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"},"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.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.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.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"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"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"}},"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.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"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":{"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"}},"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":{"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","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"},"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"},"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"},"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"},"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"},"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"},"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"},"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"},"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"},"release_url":{"description":"URL to the release page","type":"string"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"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},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"reconnect_on_use":{"type":"boolean"},"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.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"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 OriginalName → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys not present in the\nimported 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.","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"},"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"},"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 session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked"],"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"}}],"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 session ID","in":"query","name":"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/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/config":{"get":{"description":"Retrieves the current MCPProxy configuration including all server definitions, global settings, and runtime parameters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetConfigResponse"}}},"description":"Configuration retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]}},"/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"},"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"},"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, cursor, windsurf, vscode, codex, gemini)","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"},"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"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, cursor, windsurf, vscode, codex, gemini)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters"},"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"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Already connected (use force=true)"},"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/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","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/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"]}},"/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/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"},"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"},"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"},"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"},"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"},"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"},"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)"},"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"},"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.)"},"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)"},"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}/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"},"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/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)"},"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"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions 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 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/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"},"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)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}}, diff --git a/oas/swagger.yaml b/oas/swagger.yaml index 10f0fc1c..f8fa7edc 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -81,6 +81,8 @@ components: oauth_expiry_warning_hours: description: Health status settings type: number + output_validation: + $ref: '#/components/schemas/config.OutputValidationConfig' quarantine_enabled: description: |- QuarantineEnabled controls whether quarantine is active. It gates two @@ -389,6 +391,22 @@ components: type: array uniqueItems: false type: object + config.OutputValidationConfig: + description: Output-schema validation settings (Spec 056) + properties: + max_bytes: + description: structured payload byte cap; default 5<<20 + 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.RegistryEntry: properties: count: