From 66e898f1315770edbb00f9426c436bc489c4eeff Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 31 May 2026 16:57:59 +0300 Subject: [PATCH 01/23] =?UTF-8?q?docs(070):=20Registry=20=E2=80=94=20make?= =?UTF-8?q?=20discovery=20actual=20&=20easy=20to=20add=20to=20upstream=20s?= =?UTF-8?q?pec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the search-to-add loop + reach surface parity across Web UI, MCP, CLI (all share the unified AddUpstreamServer path, quarantine-by-default). Real gaps from research: CLI has no registry/search/add commands; Web UI Repositories searches but can't one-click-add; registry list hardcoded/rebuild-only; key-less registries error. Adds unified add-from-registry-result, CLI parity, Web UI loop, MCP convenience, config-driven registry list + cache refresh, and a cross-surface consistency regression. Roadmap PILLAR A. Paperclip goal da399902. --- .../checklists/requirements.md | 30 ++++ specs/070-registry-easy-upstream-add/spec.md | 151 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 specs/070-registry-easy-upstream-add/checklists/requirements.md create mode 100644 specs/070-registry-easy-upstream-add/spec.md diff --git a/specs/070-registry-easy-upstream-add/checklists/requirements.md b/specs/070-registry-easy-upstream-add/checklists/requirements.md new file mode 100644 index 00000000..ce7018c9 --- /dev/null +++ b/specs/070-registry-easy-upstream-add/checklists/requirements.md @@ -0,0 +1,30 @@ +# Specification Quality Checklist: Registry — Make Discovery Actual & Easy to Add + +**Created**: 2026-05-31 · **Feature**: [spec.md](../spec.md) + +## Content Quality +- [x] No implementation details in requirements (names files only as dependencies) +- [x] Focused on user value (easy add from registry; CLI parity) +- [x] Written for stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements testable and unambiguous +- [x] Success criteria measurable + technology-agnostic +- [x] All acceptance scenarios defined +- [x] Edge cases identified (missing install info, required key, duplicate, unreachable registry, stale cache, cross-surface drift) +- [x] Scope bounded (close the loop + parity; not building search; profiles/import out) +- [x] Dependencies + assumptions identified + +## Feature Readiness +- [x] All FRs have acceptance criteria +- [x] User scenarios cover all three surfaces (Web UI, CLI, MCP) + freshness +- [x] Meets measurable outcomes in SC +- [x] No implementation leakage + +## Notes +- Scaffolder not used (broken on this repo's fork remotes); branch `070-registry-easy-upstream-add` + artifacts created directly in standard speckit format. 070 confirmed free. +- Framing from research: search + add BOTH exist and unify through `AddUpstreamServer` (quarantine-by-default). Real gaps: CLI has zero registry commands; Web UI Repositories searches but can't one-click-add; registry list hardcoded/rebuild-only; key-less registries error. +- Plan (`/speckit.plan`) should pin: the unified "add from registry result" core signature; exact new CLI command names; whether the Web UI add lives as an AddServerModal tab vs a Repositories Add button; the config-driven registry-list schema (merge with defaults); cache-refresh control; and the cross-surface consistency regression test design. +- Strong consistency invariant (CN-004/FR-010): same server via any surface → identical upstream entry. This is the key regression test. diff --git a/specs/070-registry-easy-upstream-add/spec.md b/specs/070-registry-easy-upstream-add/spec.md new file mode 100644 index 00000000..89e205a6 --- /dev/null +++ b/specs/070-registry-easy-upstream-add/spec.md @@ -0,0 +1,151 @@ +# Feature Specification: Registry — Make MCP Server Discovery Actual & Easy to Add to Upstream + +**Feature Branch**: `070-registry-easy-upstream-add` +**Created**: 2026-05-31 +**Status**: Draft +**Lineage**: H2-2026 roadmap PILLAR A (Adopt). Extends the existing registry subsystem (`internal/registries/`) and the unified upstream-add path. Related but distinct: spec 025 (import-config), GitHub #55 / spec 057 (per-client profiles). Paperclip goal `da399902`. +**Input**: Make the MCP server registry current and make adding a discovered server to your upstream config reliable and easy across ALL three surfaces — Web UI, MCP tools, and CLI — each tested. Close the loop so discovery and add are connected everywhere, sharing one path, with quarantine-by-default preserved. + +## Clarifications + +### Session 2026-05-31 + +- Q: Is registry search missing? → A: No — search exists for 8 registries (live API + 24h cache) and the add-to-upstream path is unified through one core method that quarantines by default. The work is closing UX gaps + reaching parity, not building search. +- Q: What are the actual gaps? → A: (1) the **CLI has no registry search/add command at all**; (2) the **Web UI can search (Repositories page) but cannot one-click-add** — discovery and the Add-Server form are disconnected; (3) the registry list is **hardcoded and rebuild-only**, and a default registry needing an API key errors when unconfigured. +- Q: Add-from-registry today via MCP? → A: Works, but the agent must hand-construct the upstream config from a search result; a convenience "add from registry result" mode is desired. +- Q: Does quarantine-by-default hold on add? → A: Yes, on every surface (they share the core add path); this MUST be preserved as an invariant. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Add a discovered server to upstream from the Web UI in one flow (Priority: P1) + +A user opens the Web UI, searches the registries for a server (e.g. "github"), sees results, and clicks **Add** — the server is added to their upstream config (quarantined for review) without leaving the page or re-typing the command. Today they can search on the Repositories page but must copy a command and re-enter it in a separate Add-Server form. + +**Why this priority**: The Web UI is the primary surface for most users, and the broken search→add loop is the biggest friction. Closing it delivers the headline "easy to add from registry" value. + +**Independent Test**: In the Web UI, search a registry, click Add on a result; confirm the server appears in the servers list, quarantined, with a correct config (command/args or url, transport), with no manual re-entry. + +**Acceptance Scenarios**: + +1. **Given** registry search results in the Web UI, **When** the user clicks Add on a result, **Then** the server is added to upstream (quarantined) with a valid config derived from the result. +2. **Given** a just-added server, **When** the user views the servers list, **Then** it appears quarantined and pending approval. +3. **Given** a result that needs required input (e.g. an env var/API key), **When** adding, **Then** the user is prompted for it rather than silently adding a broken server. + +--- + +### User Story 2 — Discover and add from the CLI (Priority: P1) + +A user (or a script/agent in a terminal) lists registries, searches for a server, and adds it to upstream — entirely from the CLI. Today none of this exists on the CLI; adding means hand-editing the config file or going through the MCP protocol. + +**Why this priority**: The CLI is the automation surface and currently has a total gap (no `search`, no `registry`, no `add-from-registry`). Parity here is the clearest net-new value and serves the user's explicit "test CLI" requirement. + +**Independent Test**: Run the new CLI commands to list registries, search, and add a server from a registry; confirm the server then appears in `mcpproxy upstream list`, quarantined. + +**Acceptance Scenarios**: + +1. **Given** the CLI, **When** the user runs the registry-list command, **Then** the available registries are listed. +2. **Given** a search query, **When** the user runs the search command, **Then** normalized results (name, source, install info) are shown. +3. **Given** a chosen result, **When** the user runs the add-from-registry command, **Then** the server is added to upstream (quarantined) and appears in `upstream list`. + +--- + +### User Story 3 — Add from registry via MCP tools without hand-constructing config (Priority: P2) + +An AI agent searches via `search_servers`, then adds a chosen result to upstream by referencing it (registry + server identifier) rather than re-assembling the command/args/url by hand. The backend reconstructs the validated config. + +**Why this priority**: Reduces agent error and token cost on the MCP path (which already functions but requires manual translation). P2 because the MCP add path works today; this is an ergonomics upgrade. + +**Independent Test**: Via the MCP protocol, search then add-from-result; confirm the resulting upstream entry matches what manual construction would produce, quarantined. + +**Acceptance Scenarios**: + +1. **Given** a `search_servers` result, **When** the agent adds it by registry+identifier, **Then** the backend builds the correct upstream config and quarantines it. +2. **Given** the same logical server, **When** added via MCP vs Web UI vs CLI, **Then** the resulting upstream entry is identical. + +--- + +### User Story 4 — Keep the registry list current and resilient (Priority: P2) + +A user can add their own/private registry without rebuilding mcpproxy, manually refresh stale registry data, see how fresh results are, and not be blocked by a registry that needs an unconfigured API key (it's skipped/marked, not erroring). + +**Why this priority**: "Make registries actual" — a hardcoded, rebuild-only list with silent key failures undermines trust in discovery. P2 because the add-loop (US1–3) is the dominant value; freshness/config makes it durable. + +**Independent Test**: Add a registry via config (no rebuild) and confirm it appears in search; trigger a refresh and confirm cache age updates; configure no key for a key-requiring registry and confirm it's skipped/marked rather than failing the whole search. + +**Acceptance Scenarios**: + +1. **Given** a user-defined registry in config, **When** searching, **Then** that registry is included alongside built-in defaults (no rebuild). +2. **Given** cached registry data, **When** the user refreshes, **Then** fresh data is fetched and cache age is reflected. +3. **Given** a registry requiring an absent API key, **When** searching, **Then** it is skipped/marked unavailable and other registries still return results. + +## Requirements *(mandatory)* + +### Context & Constraints (locked) + +- **CN-001**: Preserve the unified add path — all surfaces MUST funnel into the one core add-upstream operation; do not create surface-specific add logic that could diverge. +- **CN-002**: Quarantine-by-default on add MUST hold on every surface (Constitution IV). +- **CN-003**: Extend the existing `internal/registries/` subsystem; do not build a parallel registry system. +- **CN-004**: A server added via any surface MUST produce an identical, valid upstream config entry (consistency invariant). +- **CN-005**: All three surfaces (Web UI, MCP, CLI) plus the REST backend MUST be tested. + +### Functional Requirements + +- **FR-001**: Provide a unified "add from registry result" capability in the core: given a registry id + server identifier, the backend reconstructs the validated upstream config (command/args or url, transport, env) via the existing result-normalization and adds it (quarantined). +- **FR-002**: The Web UI MUST connect search → add: a result in the discovery/Repositories flow (or an Add-Server "from registry" tab) has an Add action that calls the unified add path; no manual re-entry of the command. +- **FR-003**: The Web UI MUST prompt for required inputs (e.g. env vars / API keys a result declares) before adding, rather than adding a broken server. +- **FR-004**: The CLI MUST provide: list registries, search registries (by query, with registry/tag/limit filters), and add a server to upstream from a registry result (and a manual add via command/args or url). +- **FR-005**: The MCP `upstream_servers` tool MUST support an "add from search result" mode (registry + identifier) so agents need not hand-construct the config. +- **FR-006**: The registry list MUST be configurable — user-defined registries merge with built-in defaults without a rebuild. +- **FR-007**: The system MUST support manual refresh/invalidation of registry cache and surface cache age/freshness in results. +- **FR-008**: A registry that cannot be queried (missing key, unreachable) MUST be skipped/marked unavailable without failing the overall search. +- **FR-009**: Across all surfaces, an added server MUST be quarantined by default and appear pending approval. +- **FR-010**: The add-from-registry path MUST be covered by tests on all three surfaces (MCP protocol, CLI, Web UI via Playwright) plus a REST/curl backend test, and a regression test asserting identical upstream entries across surfaces (CN-004). + +### Key Entities + +- **Registry**: a discovery source (id, url, tags, transport hint, optional key requirement); built-in defaults + user-defined, merged. +- **Server search result (normalized)**: name, description, source registry, install info (command/args or url), declared required inputs. +- **Unified add operation**: result (or manual fields) → validated upstream config → quarantined upstream entry. +- **Registry cache**: cached per-registry results with an age/freshness indicator and manual refresh. + +## Success Criteria *(mandatory)* + +- **SC-001**: A Web UI user can go from registry search to an added (quarantined) upstream server in one flow, no manual command re-entry. +- **SC-002**: A CLI user can list registries, search, and add a server to upstream — commands that do not exist today. +- **SC-003**: An agent can add a searched server via MCP by reference, without hand-building the config. +- **SC-004**: The same logical server added via Web UI, CLI, and MCP yields an identical upstream config entry, quarantined in all three. +- **SC-005**: A user-defined registry appears in search without rebuilding mcpproxy. +- **SC-006**: A registry needing an unconfigured key does not break search; other registries still return results, and its unavailability is visible. +- **SC-007**: Registry results show freshness/cache age and can be manually refreshed. +- **SC-008**: All three surfaces + REST are covered by passing tests, including the cross-surface consistency regression. + +## Assumptions + +- Registry search exists for ~8 registries with a 24h cache, and the add-to-upstream path is unified through one core method that quarantines by default (confirmed in research). +- The Web UI Repositories page can search and `AddServerModal` is the add form; wiring them is UI work over existing REST endpoints. +- The CLI has `upstream list/logs/restart/inspect/approve` but no registry/search/add-from-registry commands (the gap). +- Result normalization already yields enough to construct a valid upstream config for stdio (command/args) and http (url) servers. +- Frontend changes require `make build` (embedded UI). + +## Dependencies + +- `internal/registries/` (registries list + search + normalization), `internal/cache/` (24h cache + `read_cache`). +- The unified add path (`internal/server` `AddUpstreamServer`) and its REST/MCP handlers (`internal/httpapi`, `internal/server/mcp.go`: `search_servers`, `list_registries`, `upstream_servers`). +- `cmd/mcpproxy/` (new CLI commands), `frontend/src/views/Repositories.vue` + `components/AddServerModal.vue`. +- Test infra: `scripts/test-api-e2e.sh`, CLI e2e, Playwright Web-UI workflow, mcpproxy-qa skill. + +## Out of Scope + +- Per-client tool-visibility profiles (GitHub #55 / spec 057) — related, referenced, not merged here. +- Bulk config import (spec 025) — separate. +- Auto-installing/running server packages beyond adding the validated upstream entry. +- Ranking/relevance improvements to registry search results (discovery quality is a separate concern). + +## Edge Cases + +- **Result missing install info** (neither command nor url derivable): adding is refused with a clear message, not a broken entry. +- **Required key/env not provided**: prompt (UI) / flag-or-error (CLI) / explicit field (MCP); never silently add broken. +- **Duplicate name** already in upstream: handled (reject or disambiguate), consistent across surfaces. +- **Registry unreachable / key absent**: skipped/marked; overall search still succeeds (FR-008). +- **Stale cache**: results show age; manual refresh available (FR-007). +- **Cross-surface drift**: the consistency regression (CN-004/FR-010) guards against surfaces producing different configs. From 69ac353e9a985564d2078b6832491b5b20424731 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 31 May 2026 17:08:25 +0300 Subject: [PATCH 02/23] docs(070): plan, research, data-model, contracts, tasks for registry easy upstream-add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0/1/2 speckit design artifacts for spec 070. Research found the search->add loop is already partially closed (Web UI Add button, CLI search/list) but via three divergent normalizations; keystone is one backend AddServerFromRegistry core op all surfaces call, guarded by a cross-surface consistency regression (CN-004/FR-010). No implementation yet — submitted for the per-spec design gate (Gate 2). Related #746 --- .../contracts/add-from-registry.md | 72 +++++++++++ .../data-model.md | 83 ++++++++++++ specs/070-registry-easy-upstream-add/plan.md | 120 ++++++++++++++++++ .../quickstart.md | 66 ++++++++++ .../research.md | 84 ++++++++++++ specs/070-registry-easy-upstream-add/tasks.md | 119 +++++++++++++++++ 6 files changed, 544 insertions(+) create mode 100644 specs/070-registry-easy-upstream-add/contracts/add-from-registry.md create mode 100644 specs/070-registry-easy-upstream-add/data-model.md create mode 100644 specs/070-registry-easy-upstream-add/plan.md create mode 100644 specs/070-registry-easy-upstream-add/quickstart.md create mode 100644 specs/070-registry-easy-upstream-add/research.md create mode 100644 specs/070-registry-easy-upstream-add/tasks.md diff --git a/specs/070-registry-easy-upstream-add/contracts/add-from-registry.md b/specs/070-registry-easy-upstream-add/contracts/add-from-registry.md new file mode 100644 index 00000000..0503b313 --- /dev/null +++ b/specs/070-registry-easy-upstream-add/contracts/add-from-registry.md @@ -0,0 +1,72 @@ +# Contracts: Add-from-Registry across surfaces + +**Feature**: 070-registry-easy-upstream-add · **Date**: 2026-05-31 +All three surfaces funnel into the single core op `server.AddServerFromRegistry(ctx, req)` (CN-001). Identical input → identical persisted `config.ServerConfig` (CN-004). + +## 1. REST (Web UI + curl) + +### Add from registry result — **NEW** +``` +POST /api/v1/registries/{registryId}/servers/{serverId}/add +Auth: X-API-Key +Body (optional): + { "name": "github", "enabled": true, "env": { "GITHUB_TOKEN": "..." } } + +200 OK: + { "success": true, + "data": { "server": { "name": "...", "protocol": "stdio|http", + "command": "...", "args": [...], "url": "...", + "quarantined": true } }, + "request_id": "..." } + +400 no_install_info | missing_required_input | duplicate_name (JSON error + request_id) +404 registry_not_found | server_not_found +``` + +### Cache refresh — **NEW (FR-007)** +``` +POST /api/v1/registries/{registryId}/refresh → 200 { "refreshed": true, "age_seconds": 0 } +``` + +### Existing (unchanged, used by the flow) +``` +GET /api/v1/registries # list (merge defaults∪config — FR-006) +GET /api/v1/registries/{registryId}/servers?q=&tag=&limit= # search; response gains age/stale (FR-007) +``` +Search response **NEW** fields per registry: `"unavailable": true, "reason": "missing_key"` (FR-008); top-level `"cache": {"age_seconds": N, "stale": bool}`. + +## 2. MCP (`upstream_servers` tool) — **NEW operation** + +```jsonc +// operation enum gains "add_from_registry" +{ "operation": "add_from_registry", + "registry": "pulse", // required — registry id + "id": "weather-server", // required — server id within the registry + "name": "weather", // optional override + "env_json": "{\"API_KEY\":\"...\"}" // optional; required if result declares inputs +} +// → standard upstream add result, quarantined: true +// errors: registry_not_found | server_not_found | no_install_info | +// missing_required_input | duplicate_name (structured MCP error) +``` +`search_servers` and `list_registries` tools unchanged in shape; `search_servers` results gain `required_inputs[]` and registry `unavailable` marking. + +## 3. CLI — **NEW `registry` command group** + +``` +mcpproxy registry list # alias of: search-servers --list-registries + [-o table|json|yaml] + +mcpproxy registry search # alias of: search-servers --registry … --search … + --registry [--tag ] [--limit N] [-o …] + +mcpproxy registry add # NEW — closes the loop on the CLI + [--name ] [--env KEY=VALUE ...] [--enabled] + # talks to running daemon via cliclient → POST /api/v1/registries/{id}/servers/{serverId}/add + # prints: "Added '' (quarantined — approve with: mcpproxy upstream approve )" + # errors mirror REST: no_install_info / missing_required_input / duplicate_name +``` +`search-servers` retained as a back-compat top-level alias (no breakage). New group mirrors the `upstream` cmd pattern (`cmd/mcpproxy/upstream_cmd.go`): config+logger load, daemon detection, `cliclient` call, `internal/cli/output` formatter. + +## Cross-surface consistency contract (CN-004 / FR-010) +A regression test (`internal/server/consistency_crosssurface_test.go`) MUST assert that adding the same `(registryId, serverId, env, name)` through the REST handler, the MCP handler, and the CLI add path yields byte-identical persisted `config.ServerConfig` (excluding the `Created` timestamp), all `Quarantined == true`. diff --git a/specs/070-registry-easy-upstream-add/data-model.md b/specs/070-registry-easy-upstream-add/data-model.md new file mode 100644 index 00000000..623c844a --- /dev/null +++ b/specs/070-registry-easy-upstream-add/data-model.md @@ -0,0 +1,83 @@ +# Phase 1 Data Model: Registry — Easy Upstream-Add + +**Feature**: 070-registry-easy-upstream-add · **Date**: 2026-05-31 + +No new persistent storage. Reuses the existing upstream BBolt bucket via `storage.SaveUpstreamServer` and the existing `mcp_config.json` `Registries` list. The entities below are mostly existing types; **new/changed fields are marked**. + +## Registry (`config.RegistryEntry` / `registries.RegistryEntry`) +Source: `internal/config/config.go:866-912`, `internal/registries/types.go:6-15`. + +| Field | Type | Notes | +|-------|------|-------| +| ID | string | Stable identifier (e.g. `pulse`). Lookup key. | +| Name | string | Display name. | +| Description | string | | +| URL | string | Human catalog URL. | +| ServersURL | string | API endpoint queried for servers. | +| Tags | []string | e.g. `verified`, `community`. | +| Protocol | string | Parser selector (`custom/pulse`, `mcp/v0`, …). | +| Count | int | Runtime-populated server count (-1 = unknown). | +| **RequiresKey** | bool | **NEW (FR-008)** — when true and no key configured, registry is skipped/marked unavailable, not erroring the whole search. | +| **Builtin** | bool | **NEW (derived, FR-006)** — true for the 5 defaults; used to render merge provenance, not persisted. | + +**Merge rule (FR-006 / decision D4)**: effective list = built-in defaults ∪ user config `Registries`, keyed by `ID`; a user entry with a colliding ID overrides the default. Today `SetRegistriesFromConfig` *replaces* — change to merge (`registry_data.go:10-42`). + +## Normalized server search result (`registries.ServerEntry`) +Source: `internal/registries/types.go:18-32`. + +| Field | Type | Notes | +|-------|------|-------| +| ID | string | Identifier within the registry — the **add-by-reference key** (FR-001/FR-005). | +| Name | string | Proposed upstream server name (override allowed). | +| Description | string | | +| URL | string | For http/remote servers → upstream `url`. | +| SourceCodeURL | string | Repo link (display only). | +| InstallCmd | string | For stdio → split into `command` + `args` **server-side** (no longer client-side). | +| Registry | string | Source registry ID. | +| RepositoryInfo | *RepositoryInfo | npm/PyPI enrichment incl. install command. | +| **RequiredInputs** | []RequiredInput | **NEW (FR-003 plumbing)** — declared env/keys needed before a working add. Best-effort (decision D3 / O1). | + +### RequiredInput (**NEW**) +| Field | Type | Notes | +|-------|------|-------| +| Name | string | Env var name (e.g. `GITHUB_TOKEN`). | +| Description | string | Optional human hint. | +| Secret | bool | Mask in UI/logs. | + +Population: (a) explicit registry payload fields where present; (b) heuristic scan of `InstallCmd`/result for `${VAR}` / `$VAR` placeholders. No rich per-registry schema in this spec (O1). + +## Unified add operation (input → output) +The keystone. Input is a **reference**, not a config blob (security decision D1 — server re-derives). + +**Input** `AddFromRegistryRequest`: +| Field | Type | Required | Notes | +|-------|------|----------|-------| +| RegistryID | string | yes | Must resolve via `FindRegistry`. | +| ServerID | string | yes | Resolved via new `FindServerByID`. | +| Name | string | no | Override the proposed name; default = result Name. | +| Env | map[string]string | conditional | Required if result declares `RequiredInputs` not otherwise satisfied. | +| Enabled | bool | no | Default true. | + +**Derivation → `config.ServerConfig`** (`internal/config/config.go:224-251`): +- stdio: `Command` + `Args` from `InstallCmd`/`RepositoryInfo`; `Protocol="stdio"`. +- http/remote: `URL` from result `URL`; `Protocol="http"`. +- `Env` merged from overrides. +- `Quarantined = cfg.DefaultQuarantineForNewServer()` (default true — CN-002, never overridable to false on this path). +- `Created = now`. + +**Output**: persisted `ServerConfig` (via `SaveUpstreamServer`) + the same server echoed back with `quarantined: true`. + +**Validation / refusal (edge cases)**: +| Condition | Result | +|-----------|--------| +| Registry not found | error `registry_not_found`. | +| Server ID not found | error `server_not_found`. | +| Neither install_cmd nor url derivable | error `no_install_info` (never persist a broken entry). | +| Required input missing | error `missing_required_input` (lists names). | +| Duplicate upstream name | error `duplicate_name` (consistent across surfaces). | + +**Consistency invariant (CN-004 / FR-010)**: the same `(RegistryID, ServerID, Env, Name)` produces a byte-identical persisted `ServerConfig` (modulo `Created` timestamp) regardless of calling surface — asserted by the cross-surface regression test. + +## Registry cache (`cache` package) +Source: `internal/cache/manager.go` (TTL 2h, `manager.go:19`). +- **NEW (FR-007)**: `Refresh(key)` / `Invalidate(key)` to force re-fetch; surface `Age = now - record.CreatedAt` and `Stale = IsExpired()` on search responses. diff --git a/specs/070-registry-easy-upstream-add/plan.md b/specs/070-registry-easy-upstream-add/plan.md new file mode 100644 index 00000000..35d64b29 --- /dev/null +++ b/specs/070-registry-easy-upstream-add/plan.md @@ -0,0 +1,120 @@ +# Implementation Plan: Registry — Make Discovery Actual & Easy to Add to Upstream + +**Branch**: `070-registry-easy-upstream-add` | **Date**: 2026-05-31 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/070-registry-easy-upstream-add/spec.md` + +## Summary + +Close the registry **search → add** loop and reach parity across Web UI, MCP, and CLI by routing every surface through **one backend core operation** that re-derives a validated, quarantined upstream config from a registry result. Research ([research.md](./research.md)) found the loop is *already partially closed* (Web UI has an Add button; CLI already lists+searches) but via **three divergent normalizations** (client-side JS, hand-built MCP args, none on CLI). The plan's keystone is de-duplicating that normalization into the core (FR-001) and guarding it with a cross-surface consistency regression (FR-010/CN-004). Remaining registry-resilience gaps (merge-with-defaults, cache freshness/refresh, key-absent skip) are P2. + +## Technical Context + +**Language/Version**: Go 1.24 (toolchain go1.24.10); TypeScript 5.9 / Vue 3.5 (frontend) +**Primary Dependencies**: Cobra (CLI), Chi router (REST), `mark3labs/mcp-go` (MCP), BBolt (storage), Zap (logging), Pinia/Vite (frontend). No new external deps. +**Storage**: BBolt (`~/.mcpproxy/config.db`) upstream bucket — reuses `SaveUpstreamServer`; **no schema change**. Registry list stays in `mcp_config.json` (`Registries []RegistryEntry`). +**Testing**: `go test ./internal/... -race`; `scripts/test-api-e2e.sh` (REST/curl); CLI e2e; Playwright Web-UI workflow (`e2e/playwright`); cross-surface consistency Go integration test. +**Target Platform**: macOS/Linux/Windows desktop (personal edition); server edition unaffected (no build-tagged code touched). +**Project Type**: web (Go backend + embedded Vue frontend). +**Performance Goals**: No regression to the BM25/tool hot path (Constitution I). Registry fetch stays off the request path (cached, 2h TTL). +**Constraints**: Quarantine-by-default invariant (CN-002) on every surface; identical persisted config across surfaces (CN-004); CLAUDE.md 40k-char CI gate (keep delta minimal — detail lives here). +**Scale/Scope**: ~6 registries; result sets ≤50; 4 surfaces (REST/MCP/CLI/Web) + 1 consistency regression. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Note | +|-----------|--------|------| +| I. Performance at Scale | ✅ PASS | No hot-path change; registry fetch cached and off-request. | +| II. Actor-Based Concurrency | ✅ PASS | Core op is a synchronous storage write through the existing manager; no new mutexes. | +| III. Configuration-Driven | ✅ PASS | Registry list stays config-driven; merge-with-defaults preserves hot-reload. | +| IV. Security by Default | ✅ PASS | Quarantine-by-default preserved everywhere (CN-002); server **re-derives** config from the authoritative registry fetch — never trusts a client-supplied config blob. | +| V. TDD | ✅ PASS | Consistency regression + per-surface tests authored before implementation. | +| VI. Documentation Hygiene | ✅ PASS | CLAUDE.md MCP-tool + CLI tables updated minimally; detail in spec. | + +**Result**: No violations → Complexity Tracking not required. + +## Project Structure + +### Documentation (this feature) + +```text +specs/070-registry-easy-upstream-add/ +├── spec.md # Feature spec (exists) +├── plan.md # This file +├── research.md # Phase 0 — grounded map + the 3 stale-premise discrepancies +├── data-model.md # Phase 1 — entities (Registry, normalized result, add op) +├── quickstart.md # Phase 1 — per-surface verification recipe +├── contracts/ # Phase 1 — REST + MCP + CLI contracts +│ └── add-from-registry.md +├── checklists/ +│ └── requirements.md # (exists) +└── tasks.md # Phase 2 — /speckit.tasks output (not created by plan) +``` + +### Source Code (repository root) — files this feature touches + +```text +internal/registries/ +├── search.go # SearchServers + normalization (reuse; add FindServerByID helper) +├── registry_data.go # SetRegistriesFromConfig → MERGE defaults∪config (FR-006) +└── types.go # ServerEntry → add RequiredInputs[] (FR-003 plumbing) + +internal/server/ +├── add_from_registry.go # NEW — core op: (registryID, serverID, overrides) → validated quarantined ServerConfig (FR-001) [KEYSTONE] +└── mcp.go # upstream_servers: new operation "add_from_registry" (FR-005) + +internal/httpapi/ +└── server.go # NEW route POST /api/v1/registries/{id}/servers/{serverId}/add (FR-002 backend); optional .../refresh (FR-007) + +internal/cache/ +└── manager.go # Manual refresh/invalidate + age surfaced (FR-007) + +cmd/mcpproxy/ +└── registry_cmd.go # NEW — `registry list|search|add` group via cliclient→daemon (FR-004); search-servers kept as alias + +internal/cliclient/ +└── client.go # NEW methods: SearchRegistry, ListRegistries, AddFromRegistry + +frontend/src/ +├── services/api.ts # addServerFromRepository → call new REST add endpoint (stop client-side parse) (FR-002) +├── views/Repositories.vue # required-input prompt + data-test attrs (FR-003, FR-010) +└── components/AddServerModal.vue # accept registry pre-fill + data-test attrs + +tests/ (co-located *_test.go + e2e) +├── internal/server/add_from_registry_test.go # core op unit tests +├── internal/server/consistency_crosssurface_test.go # KEYSTONE regression (CN-004/FR-010) +├── e2e/cli/registry_add_test.* # CLI e2e +└── e2e/playwright/registry-add.spec.ts # Web UI Playwright +``` + +**Structure Decision**: Web application (Go backend + embedded Vue). The keystone is a new backend core file `internal/server/add_from_registry.go`; all surfaces (REST handler, MCP operation, CLI command, Web UI) are thin callers of it. This is the structural expression of CN-001/CN-004. + +## Phased delivery (maps to user stories / priorities) + +**Phase A — P1 keystone (US1+US2+US3 core)** +1. `internal/registries`: add `FindServerByID(ctx, registryID, id)` returning a normalized `ServerEntry` (reuse SearchServers). +2. `internal/server/add_from_registry.go`: `AddServerFromRegistry(ctx, registryID, serverID, overrides)` → validate → `ServerConfig` (command/args **or** url, transport, env) → quarantine-by-default → `SaveUpstreamServer`. Refuse on missing install info / missing required input. +3. Expose: REST `POST /api/v1/registries/{id}/servers/{serverId}/add`; MCP `upstream_servers operation=add_from_registry`; CLI `registry add`. +4. Repoint Web UI `addServerFromRepository` to the REST endpoint (delete client-side `install_cmd.split`). +5. **Consistency regression** (CN-004/FR-010): same result via REST/MCP/CLI → identical persisted config. + +**Phase B — P2 resilience (US4 + FR-003 depth)** +6. `SetRegistriesFromConfig`: merge defaults ∪ config (FR-006). +7. Cache manual refresh + freshness on results (FR-007). +8. Key-absent skip/mark-unavailable plumbing (FR-008). +9. Required-input prompting end-to-end (FR-003): Web prompt, CLI `--env`, MCP structured error. + +**Phase C — tests + docs (FR-010, Constitution VI)** +10. Per-surface tests: MCP protocol, CLI e2e, Playwright Web UI, REST/curl. +11. CLAUDE.md MCP-tool + CLI table deltas (mind 40k gate); spec amendment per O3. + +## Risks / decisions deferred to the design gate (Gate 2) +- **Stale-premise reframing (O3)** — spec US1/US2 describe gaps that are already partly built; the real work is de-dup. Needs human ratification before implementation. +- **FR-003 depth (O1)** — registries may not declare required inputs; heuristic vs deferred. +- **FR-008 demo (O2)** — ship a key-requiring registry or just plumbing. +- **P2 in-scope vs follow-up (O4)** — confirm D3/D4/D5 stay in this spec. + +## Complexity Tracking + +No constitution violations → no entries required. diff --git a/specs/070-registry-easy-upstream-add/quickstart.md b/specs/070-registry-easy-upstream-add/quickstart.md new file mode 100644 index 00000000..63e6d6b0 --- /dev/null +++ b/specs/070-registry-easy-upstream-add/quickstart.md @@ -0,0 +1,66 @@ +# Quickstart / Verification: Registry Easy Upstream-Add + +**Feature**: 070-registry-easy-upstream-add · **Date**: 2026-05-31 +Per-surface manual verification that the search→add loop closes and stays consistent. Use a throwaway data-dir (memory: app is config.db-authoritative — never touch real `~/.mcpproxy`). + +## Setup +```bash +make build # embeds frontend +rm -rf /tmp/reg70 && mkdir -p /tmp/reg70 +cat > /tmp/reg70/mcp_config.json <<'EOF' +{ "listen": "127.0.0.1:18070", "data_dir": "/tmp/reg70", "api_key": "reg70", + "enable_web_ui": true, "enable_socket": true, "telemetry": {"enabled": false}, + "mcpServers": [] } +EOF +./mcpproxy serve --config=/tmp/reg70/mcp_config.json --log-level=info & +until curl -sf -H "X-API-Key: reg70" http://127.0.0.1:18070/api/v1/status >/dev/null; do sleep 1; done +``` + +## US2 — CLI (list → search → add) +```bash +./mcpproxy registry list --config=/tmp/reg70/mcp_config.json # shows merged registries (FR-006) +./mcpproxy registry search github --registry pulse -o json # normalized results +./mcpproxy registry add pulse --name gh-test # NEW — closes loop +./mcpproxy upstream list --config=/tmp/reg70/mcp_config.json # gh-test present, QUARANTINED +``` +**Pass**: `gh-test` appears quarantined; `registry add` printed the approve hint. + +## US3 — MCP (add by reference) +```bash +# via tools/call REST shim or an MCP client: +curl -s -H "X-API-Key: reg70" -X POST http://127.0.0.1:18070/api/v1/tools/call \ + -d '{"tool_name":"upstream_servers","arguments":{"operation":"add_from_registry","registry":"pulse","id":"","name":"gh-mcp"}}' +``` +**Pass**: returns the server with `quarantined:true`; no hand-built command/args needed. + +## US1 — Web UI (search → one-click add → prompt) +``` +open "http://127.0.0.1:18070/ui/?apikey=reg70" → Repositories tab +``` +- Search `github`, click **Add to MCP** on a result. +- If the result declares required inputs, a prompt appears (FR-003) before add. +- Server appears in the list, **quarantined**. +**Pass**: no manual command re-entry; server quarantined; backend (not client JS) derived the config. + +## US4 — resilience +```bash +# FR-007 freshness + manual refresh: +curl -s -H "X-API-Key: reg70" http://127.0.0.1:18070/api/v1/registries/pulse/servers?q=git | jq .cache +curl -s -H "X-API-Key: reg70" -X POST http://127.0.0.1:18070/api/v1/registries/pulse/refresh +# FR-008 key-absent: a RequiresKey registry with no key → marked unavailable, others still return. +``` + +## SC-004 — cross-surface consistency (the keystone) +```bash +go test ./internal/server/ -run TestAddFromRegistry_CrossSurfaceConsistency -race -v +``` +**Pass**: same `(registry, serverId, env, name)` via REST/MCP/CLI → byte-identical persisted `ServerConfig` (modulo `Created`), all quarantined. + +## Regression gates (must pass before pre-merge) +```bash +./scripts/run-linter.sh +go test ./internal/... -race +go test ./internal/runtime/... -race # approval-hash stability canary (memory) +./scripts/test-api-e2e.sh +# Playwright: e2e/playwright registry-add.spec.ts (data-test attrs added on Repositories.vue/AddServerModal.vue) +``` diff --git a/specs/070-registry-easy-upstream-add/research.md b/specs/070-registry-easy-upstream-add/research.md new file mode 100644 index 00000000..99b61991 --- /dev/null +++ b/specs/070-registry-easy-upstream-add/research.md @@ -0,0 +1,84 @@ +# Phase 0 Research: Registry — Make Discovery Actual & Easy to Add to Upstream + +**Feature**: 070-registry-easy-upstream-add · **Date**: 2026-05-31 +**Method**: Direct code mapping of the existing registry/search/add subsystems across all three surfaces (Web UI / MCP / CLI) + REST backend, with file:line provenance (doctrine S-1). + +## Executive summary — the spec premise is partly stale + +The spec (Clarifications session 2026-05-31) was written on the premise that the Web UI cannot one-click-add and the CLI has **zero** registry commands. Direct code inspection shows both are **already partially built**. The real, narrower work is **architectural de-duplication**: the registry-result→upstream-config normalization is implemented three different ways (client-side JS, hand-built MCP args, absent on CLI), which directly threatens the CN-004 consistency invariant. The keystone deliverable is **one backend core operation** that all surfaces call. + +This reframing must be ratified at the design gate before implementation (Gate 2). + +## What already exists (with provenance) + +### Backend / core +- **Registry list IS config-driven** — `SetRegistriesFromConfig(cfg)` loads `cfg.Registries` (`internal/registries/registry_data.go:10-42`); 5 built-in defaults in `internal/config/config.go:866-912` (pulse, docker-mcp-catalog, fleur, azure-mcp-demo, remote-mcp-servers); hardcoded `smithery` only as the no-config fallback (`registry_data.go:29-40`). **No rebuild needed** to add a registry — FR-006 is largely satisfied at the storage level. +- **Search + normalization** — `registries.SearchServers(ctx, registryID, tag, query, limit, guesser)` (`internal/registries/search.go:31-75`); per-protocol parsers extract `InstallCmd`/`SourceCodeURL`; `applyBatchRepositoryGuessing` (`search.go:155-219`) enriches with npm/PyPI install commands. Result type `ServerEntry` (`internal/registries/types.go:18-32`). +- **Unified add (storage)** — `storage.SaveUpstreamServer(*config.ServerConfig)` (`internal/storage/manager.go:83-110`); MCP handler `handleAddUpstream` (`internal/server/mcp.go:3381-3628`). Quarantine-by-default via `cfg.DefaultQuarantineForNewServer()` (`internal/config/config.go:1005-1007`, default true). CN-002 holds. +- **Cache** — `internal/cache/manager.go`; **TTL is 2h, not 24h** (`manager.go:19`); cleanup every 10m; **no manual refresh/invalidate API**; no freshness/age surfaced to results. + +### MCP surface +- `search_servers` (`internal/server/mcp.go:703-724`, handler `:864-943`), `list_registries` (`:728-735`, handler `:946-986`), `upstream_servers` (`:629-675`, handler dispatch `:2384` → `handleAddUpstream`). +- `upstream_servers add` requires **hand-constructed** `command`/`args_json`/`url` — no "add from search result by reference" mode (the FR-005 gap). + +### REST surface +- `GET /api/v1/registries` → `handleListRegistries` (`internal/httpapi/server.go:3901-3946`). +- `GET /api/v1/registries/{id}/servers?q=&tag=&limit=` → `handleSearchRegistryServers` (`:3964-4040`). +- `POST /api/v1/servers` → `handleAddServer` (`:1277-1361`); quarantine default at `:1317`. Takes already-built fields — **no "add from registry result" body mode**. + +### CLI surface +- **`search-servers` ALREADY EXISTS** (`cmd/mcpproxy/main.go:234-349`) with `--list-registries`, `--registry`, `--search`, `--tag`, `--limit`, `-o table|json|yaml`. Runs **standalone, in-process** (loads config, calls `registries.SearchServers` directly — `main.go:286-308`), NOT through the running daemon. +- **The real CLI gap**: results only print; there is **no add-from-result command** — the user must hand-copy into `upstream add`. Closing search→add on the CLI is the genuine net-new work (FR-004 remainder). + +### Web UI surface +- `Repositories.vue` searches (`loadRegistries`/`searchServers`, lines 301-338) and **already renders an "Add to MCP" button** (lines 161-168) → `api.addServerFromRepository(server)` (`frontend/src/services/api.ts:646-678`). +- **`addServerFromRepository` parses `install_cmd` CLIENT-SIDE** (`api.ts:662-667`: `install_cmd.split(' ')`) and calls `upstream_servers add`. This is brittle (issue #483 history: snake_case casing bug) and is **the CN-001 violation** — surface-specific add logic that diverges from MCP/CLI. +- **No prompt for required inputs** (env/API keys) before adding (the FR-003 gap). +- `AddServerModal.vue` collects name/type/command/args/env/url/working_dir/quarantined — but accepts **no pre-fill props** from a registry result. +- **No `data-test` attributes** on either component — must be added for the Playwright regression (FR-010). + +## Key decisions + +### D1 — Keystone: one backend "add from registry result" core operation (FR-001) +**Decision**: Add a single core function that takes `(registryID, serverID/name, overrides)`, re-runs the existing `registries.SearchServers`/normalization **server-side**, builds a validated `config.ServerConfig` (command/args **or** url, transport, env), and routes through the existing quarantine-by-default add path. +**Rationale**: Eliminates the three divergent normalizations (frontend JS, hand-built MCP, none on CLI). Directly enforces CN-001 (unified path) and CN-004 (identical entry across surfaces). The normalization already exists (`search.go`); we are relocating the *consumer* of it into the core, not rebuilding search. +**Alternatives rejected**: (a) Keep parsing per-surface and add a shared TS+Go duplicate — rejected, guarantees drift. (b) Pass the full normalized result object from client to a generic add endpoint — rejected, lets a malicious/stale client inject arbitrary config; server must re-derive from the authoritative registry fetch. + +### D2 — Expose the core op on REST + MCP; repoint Web UI; add CLI add (FR-002/004/005) +**Decision**: +- REST: `POST /api/v1/registries/{registryId}/servers/{serverId}/add` (body: optional `env`, `name` override, `enabled`) → core op. +- MCP: new `upstream_servers` operation `add_from_registry` with params `registry`, `id` (server identifier), optional `env_json`, `name`. +- CLI: `mcpproxy registry list`, `mcpproxy registry search`, `mcpproxy registry add [--env K=V] [--name N]` — a `registry` command group that talks to the **running daemon** via `cliclient` (mirrors `upstream` cmd pattern, `cmd/mcpproxy/upstream_cmd.go`). `search-servers` is retained as a back-compat alias. +- Web UI: change `addServerFromRepository` to call the new REST add endpoint (server derives config); add a required-input prompt in `AddServerModal`/Repositories. +**Rationale**: Each surface calls the same core; the Web UI stops parsing client-side. CLI add goes through the daemon so it shares the live config/registry list (consistency). +**Alternatives rejected**: Reusing the standalone in-process `search-servers` for add — rejected, it bypasses the running daemon's config/quarantine and would re-introduce divergence. + +### D3 — Required-input prompting (FR-003) +**Decision**: Normalized result carries a `required_inputs[]` (env var names a result declares). Add refuses (with a clear message) when a required input is absent; Web UI prompts, CLI errors instructing `--env`, MCP returns a structured "missing required input" error. +**Rationale**: "Never silently add a broken server" (edge case in spec). **Open question O1**: today's `ServerEntry` does not model declared required inputs — most registries don't expose them. Scope decision needed at the gate (see Open Questions). + +### D4 — Registry list merge + freshness (FR-006/007) +**Decision**: `SetRegistriesFromConfig` currently **replaces** defaults with config. Change to **merge** (defaults ∪ user-defined, user wins on ID collision) so adding one custom registry doesn't drop the 5 built-ins. Add a manual cache-refresh control (REST `POST .../refresh` or a `--refresh` flag) and surface cache age/freshness on search results. +**Rationale**: FR-006 ("merge with built-in defaults") and FR-007 (freshness + manual refresh) are the genuine remaining registry-resilience gaps. + +### D5 — Key-absent resilience (FR-008) +**Decision**: None of the 5 default registries require an API key today, so there is no live failure to fix. Model an optional per-registry `requires_key` hint; when set and the key is absent, **skip that registry and mark it unavailable** in the aggregated result rather than failing the whole search. Implement defensively (search already isolates per-registry fetch errors — `search.go:78-107`). +**Rationale**: Satisfies FR-008/SC-006 without inventing a key-requiring default. **Open question O2**: do we ship a key-requiring registry (e.g. Smithery) to exercise this, or just the resilience plumbing? Gate decision. + +### D6 — Cross-surface consistency regression (FR-010 / CN-004) — keystone test +**Decision**: A Go integration test that adds the **same** logical registry result via the core op as invoked by (a) the REST handler, (b) the MCP handler, and (c) the CLI add path, then asserts the persisted `config.ServerConfig` entries are byte-identical (modulo timestamp). Plus the three per-surface tests (MCP protocol, CLI e2e, Playwright Web UI) and a REST/curl test. +**Rationale**: This is the single most valuable artifact — it mechanically prevents the divergence that D1 removes from re-appearing. + +## Open questions for the design gate (Gate 2) +- **O1 (FR-003 depth)**: Do registries actually declare required env/keys in their result payloads? If not, "required inputs" is best-effort (heuristic from install_cmd `${VAR}` patterns) vs deferred. Recommend: implement the plumbing + heuristic, defer rich per-registry schemas. +- **O2 (FR-008 demo)**: Ship a key-requiring registry to exercise skip-on-missing-key, or just the resilience plumbing + unit test? Recommend: plumbing + unit test, no new key-requiring default. +- **O3 (spec amendment)**: The spec's US1/US2 "gaps" are stale (Web UI add and CLI search/list already exist). Recommend amending spec Clarifications to reflect the architectural reframing (de-dup normalization) so SC/acceptance match reality. +- **O4 (scope cut)**: P1 = D1 (core op) + D2 (REST/MCP/CLI-add/Web-repoint) + D6 (consistency regression). P2 = D3/D4/D5 (required-inputs, merge+freshness, key-skip). Confirm P2 stays in this spec vs a follow-up. + +## Constitution alignment +- **I Performance**: no hot-path change; registry fetch is already off the request path (cached). ✅ +- **II Actor concurrency**: core op is a synchronous storage write through existing manager; no new locks. ✅ +- **III Config-driven**: registry list stays in `mcp_config.json`; merge change keeps hot-reload. ✅ +- **IV Security-by-default**: quarantine-by-default preserved on every surface (CN-002); server re-derives config from authoritative registry fetch (no client-injected config). ✅ +- **V TDD**: consistency regression + per-surface tests written first. ✅ +- **VI Docs**: CLAUDE.md MCP-tool + CLI tables updated; note the 40k-char CI gate (memory) — put detail in this spec, minimal CLAUDE.md delta. ✅ diff --git a/specs/070-registry-easy-upstream-add/tasks.md b/specs/070-registry-easy-upstream-add/tasks.md new file mode 100644 index 00000000..917a23c7 --- /dev/null +++ b/specs/070-registry-easy-upstream-add/tasks.md @@ -0,0 +1,119 @@ +--- +description: "Task list for 070-registry-easy-upstream-add" +--- + +# Tasks: Registry — Make Discovery Actual & Easy to Add to Upstream + +**Input**: Design documents from `/specs/070-registry-easy-upstream-add/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/add-from-registry.md, quickstart.md + +**Tests**: REQUIRED — Constitution V (TDD) and FR-010 mandate tests on all three surfaces + REST + a cross-surface consistency regression. Test tasks come before their implementation (red-green). + +**Organization**: By user story (US1–US4 from spec.md). The keystone core op (Foundational) is the shared dependency for US1/US2/US3. + +**GATE NOTE**: This tasks list is part of the design submitted at the per-spec design gate (Gate 2). **No task below may begin until the gate is approved.** Implementation happens in an isolated worktree; PRs are opened but never self-merged (Gate 3). + +## Path Conventions +Web app: Go backend (`internal/`, `cmd/`) + embedded Vue frontend (`frontend/src/`). Paths are repo-root absolute. + +--- + +## Phase 1: Setup + +- [ ] T001 Create isolated worktree `git worktree add ../mcpproxy-go-746 -b 746-registry-add` and confirm `make build` is green before any change (baseline). +- [ ] T002 [P] Add `data-test` attribute convention stubs to `frontend/src/views/Repositories.vue` and `frontend/src/components/AddServerModal.vue` (none exist today) so later Playwright tasks have hooks. + +--- + +## Phase 2: Foundational (BLOCKING — keystone, must complete before US1/US2/US3) + +**Purpose**: The single backend core op that every surface calls (FR-001 / CN-001 / CN-004). + +- [ ] T003 [P] Extend `registries.ServerEntry` with `RequiredInputs []RequiredInput` and add the `RequiredInput` type in `internal/registries/types.go` (FR-003 plumbing per data-model.md). +- [ ] T004 Add `FindServerByID(ctx, registryID, serverID string, guesser) (*ServerEntry, error)` in `internal/registries/search.go` (reuse `SearchServers`; returns `server_not_found` when absent). +- [ ] T005 [US-core] Write FAILING unit tests for the core op in `internal/server/add_from_registry_test.go`: stdio result→command/args; http result→url; quarantine-by-default true; refusal cases (`no_install_info`, `missing_required_input`, `duplicate_name`, `registry_not_found`, `server_not_found`). +- [ ] T006 [US-core] Implement `AddServerFromRegistry(ctx, req AddFromRegistryRequest) (*config.ServerConfig, error)` in `internal/server/add_from_registry.go`: resolve registry+server, derive validated `config.ServerConfig`, force `Quarantined = cfg.DefaultQuarantineForNewServer()`, persist via `SaveUpstreamServer`. Make T005 pass. +- [ ] T007 [US-core] Implement required-input detection helper (explicit fields + `${VAR}` heuristic) feeding `RequiredInputs`; covered by T005 cases. + +**Checkpoint**: Core op green (`go test ./internal/server/ -run TestAddFromRegistry -race`). All surfaces below are thin callers. + +--- + +## Phase 3: User Story 2 — Discover & add from the CLI (P1) 🎯 MVP + +**Goal**: Close search→add on the CLI (the genuine net-new gap; `search-servers` list/search already exist). +**Independent test**: `mcpproxy registry list` → `registry search` → `registry add ` → server appears quarantined in `upstream list`. + +- [ ] T008 [P] [US2] Add `cliclient` methods `ListRegistries`, `SearchRegistry`, `AddFromRegistry` in `internal/cliclient/client.go` (mirror `GetServers`/`ApproveTools` patterns). +- [ ] T009 [US2] Write FAILING CLI e2e test in `e2e/cli/registry_add_test` (or `scripts/test-*`): list→search→add→assert quarantined entry via running daemon. +- [ ] T010 [US2] Create `cmd/mcpproxy/registry_cmd.go` with `registry list|search|add` group (Cobra), wired to `cliclient` + `internal/cli/output` formatter; register in `cmd/mcpproxy/main.go`. Keep `search-servers` as a back-compat alias. +- [ ] T011 [US2] `registry add` `--env KEY=VALUE`, `--name`, `--enabled` flags; on `missing_required_input` print actionable error naming the `--env` keys. Make T009 pass. + +**Checkpoint**: CLI MVP independently deliverable. + +--- + +## Phase 4: User Story 3 — Add from registry via MCP without hand-constructing config (P2→ promoted with core) + +**Goal**: `upstream_servers` gains `add_from_registry` by reference. +**Independent test**: MCP `upstream_servers operation=add_from_registry {registry,id}` → quarantined entry equal to manual construction. + +- [ ] T012 [US3] Write FAILING MCP handler test in `internal/server/mcp_*_test.go`: `add_from_registry` happy path + `missing_required_input` structured error. +- [ ] T013 [US3] Add `add_from_registry` to the `upstream_servers` operation enum + params (`registry`,`id`,`name`,`env_json`) in the tool schema (`internal/server/mcp.go:629-675`) and dispatch to `AddServerFromRegistry`. Make T012 pass. + +--- + +## Phase 5: User Story 1 — Web UI one-flow add (P1) + +**Goal**: Repoint the existing Add button to the backend core op (stop client-side parsing) + prompt for required inputs. +**Independent test**: Playwright — search, click Add, (prompt if required), server appears quarantined; no client-side `install_cmd.split`. + +- [ ] T014 [US1] Add REST route `POST /api/v1/registries/{registryId}/servers/{serverId}/add` → `AddServerFromRegistry` in `internal/httpapi/server.go`; FAILING REST/curl test first (in `scripts/test-api-e2e.sh` or a handler test). +- [ ] T015 [US1] Replace `addServerFromRegistry`'s client-side `install_cmd.split` (`frontend/src/services/api.ts:646-678`) with a call to the new REST endpoint (server derives config). +- [ ] T016 [US1] Add required-input prompt UI in `frontend/src/views/Repositories.vue` / `AddServerModal.vue` (render `required_inputs[]`; block add until provided) with `data-test` hooks. +- [ ] T017 [US1] Write Playwright spec `e2e/playwright/registry-add.spec.ts`: search→Add→prompt→quarantined; `make build` to embed UI; run green. + +--- + +## Phase 6: User Story 4 — Keep the registry list current & resilient (P2) + +**Goal**: merge-with-defaults, cache freshness/refresh, key-absent skip. +**Independent test**: per quickstart US4. + +- [ ] T018 [P] [US4] Change `SetRegistriesFromConfig` to MERGE built-in defaults ∪ config by ID (`internal/registries/registry_data.go:10-42`) + unit test asserting custom entry doesn't drop the 5 defaults (FR-006). +- [ ] T019 [P] [US4] Add `Refresh`/`Invalidate` + age/`stale` to `internal/cache/manager.go`; surface `cache:{age_seconds,stale}` on `GET /registries/{id}/servers` and add `POST /api/v1/registries/{id}/refresh` (FR-007). +- [ ] T020 [US4] Add `RequiresKey` to registry entry + skip/mark `unavailable:{reason}` when key absent without failing overall search (`internal/registries/search.go`); unit test (FR-008/SC-006). + +--- + +## Phase 7: KEYSTONE regression + Polish (FR-010 / CN-004) + +- [ ] T021 [US-core] Cross-surface consistency regression `internal/server/consistency_crosssurface_test.go`: add same `(registry,serverId,env,name)` via REST + MCP + CLI add path → assert byte-identical persisted `config.ServerConfig` (modulo `Created`), all `Quarantined==true` (SC-004). +- [ ] T022 [P] Run full gates: `./scripts/run-linter.sh`; `go test ./internal/... -race`; **`go test ./internal/runtime/... -race`** (approval-hash stability canary — memory); `./scripts/test-api-e2e.sh`. +- [ ] T023 [P] Docs: minimal CLAUDE.md MCP-tool + CLI table delta (mind 40k-char gate — `wc -c` first); update `docs/` registry/CLI reference. +- [ ] T024 Apply the gate-approved decisions on O1–O4 (required-input depth, key-demo, spec amendment, P2 scope) before opening the PR. + +--- + +## Dependencies & order + +- **Phase 1 → Phase 2** (keystone) blocks Phases 3/4/5. +- **Phase 2 (T003–T007)** is the shared dependency for US1/US2/US3. +- **US2 (Phase 3)** is the MVP — smallest independently shippable slice once the core op exists. +- **US4 (Phase 6)** is independent of US1/2/3 (registry resilience) — can parallelize after Phase 2. +- **T021 (consistency regression)** requires all three add surfaces (T011, T013, T014) present. + +## Parallel opportunities +- T002/T003 (different files) in setup/foundational. +- After Phase 2: US2 (T008,T010) ∥ US4 (T018,T019,T020) — disjoint files. +- Polish T022/T023 [P]. + +## Implementation strategy (MVP-first) +1. **MVP** = Phase 1 + Phase 2 (core op) + Phase 3 (CLI add) → demonstrable closed loop on the automation surface. +2. Then US3 (MCP), US1 (Web UI), US4 (resilience). +3. Finish with T021 keystone regression + gates + docs. + +## Gate reminders (doctrine) +- Do not start T001+ until Gate 2 (design) is approved. +- Worktree only; never commit to `main`. +- Open PR with `Related #746`; never self-merge (Gate 3). QA pass + Critic approval required before requesting the pre-merge gate. From 563b66a9e613872a8504331e7351645db7c812ff Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 31 May 2026 17:13:00 +0300 Subject: [PATCH 03/23] docs(070): add registry research-refinement notes for planning --- .../checklists/requirements.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/specs/070-registry-easy-upstream-add/checklists/requirements.md b/specs/070-registry-easy-upstream-add/checklists/requirements.md index ce7018c9..6df2ad73 100644 --- a/specs/070-registry-easy-upstream-add/checklists/requirements.md +++ b/specs/070-registry-easy-upstream-add/checklists/requirements.md @@ -28,3 +28,11 @@ - Framing from research: search + add BOTH exist and unify through `AddUpstreamServer` (quarantine-by-default). Real gaps: CLI has zero registry commands; Web UI Repositories searches but can't one-click-add; registry list hardcoded/rebuild-only; key-less registries error. - Plan (`/speckit.plan`) should pin: the unified "add from registry result" core signature; exact new CLI command names; whether the Web UI add lives as an AddServerModal tab vs a Repositories Add button; the config-driven registry-list schema (merge with defaults); cache-refresh control; and the cross-surface consistency regression test design. - Strong consistency invariant (CN-004/FR-010): same server via any surface → identical upstream entry. This is the key regression test. + +## Research refinements (2026-05-31, grounding agent) +- CONFIRMED: search works on all 3 surfaces; add-from-result unified on NONE. Only Web UI auto-adds, via a LOSSY client-side `install_cmd.split(' ')` in `frontend/src/services/api.ts:646-678` that drops env/oauth/working_dir and can break on quoted args. This is the core gap FR-001/CN-004 fix. +- The one source of truth to build: a backend `BuildServerConfigFromRegistryEntry()` (ServerEntry→ServerConfig) that REST + MCP `upstream_servers` + CLI `upstream add` all call → identical quarantined entry (the cross-surface regression in FR-010). +- Registry list is static config (`config.go:866-912`, 5 defaults: pulse/docker/fleur/azure-demo/remote); official `modelcontextprotocol/registry` parser EXISTS (`search.go:115`) but is NOT wired as a default — FR for currency should add it. Server data fetched live per search (10s timeout, NO cache) → a down registry errors the whole search (`runtime.go:1506`); FR-008 isolation + a short-TTL cache needed. +- #483 data-contract fragility: camelCase(runtime)→snake_case(REST)→TS three-hop mapping; consistent now but brittle — collapse to one canonical shape (a hardening FR). +- 025-import-config is an EMPTY STUB (dir only); configimport (`internal/configimport/`) is the separate client-import subsystem — keep distinct. Issue #55 = per-client scoping (adjacent, out of scope). +- Test: golden registry fixture → add via REST+MCP+CLI → byte-identical ServerConfig + all quarantined (the spec's core acceptance). Use a stub registry HTTP server. From 1fd09a44d9413ac1967ccdbe67d194b68f522e0b Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 31 May 2026 19:30:33 +0300 Subject: [PATCH 04/23] feat(registries): AddServerFromRegistry keystone core op (spec 070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single backend op that turns a registry reference (registryId + serverId + overrides) into a validated, quarantined upstream server. Every surface (REST/MCP/CLI) will funnel through this one normalization (CN-001); identical input yields an identical persisted config (CN-004). The client sends a reference, never a config blob — the server re-derives it (security D1), so a buggy/compromised client cannot smuggle command/args or disable quarantine. - registries.ServerEntry gains RequiredInputs ([]RequiredInput) - registries.FindServerByID + sentinel errors (registry/server not found) - registries.DetectRequiredInputs: explicit + ${VAR}/$VAR heuristic scan - server.AddServerFromRegistry orchestrator + pure buildServerConfigFromEntry - quarantine-by-default (CN-002, never client-overridable); refusals on no_install_info / missing_required_input / duplicate_name - RegistryAddErrorCode maps errors to stable cross-surface codes Phase 1+2 of MCP-746. Tests: go test ./internal/server/ -run TestAddFromRegistry -race (green); registries input/lookup unit tests; build + linter clean. Co-Authored-By: Paperclip --- internal/registries/inputs.go | 91 +++++++++ internal/registries/inputs_test.go | 57 ++++++ internal/registries/search.go | 43 +++++ internal/registries/types.go | 15 ++ internal/server/add_from_registry.go | 213 ++++++++++++++++++++++ internal/server/add_from_registry_test.go | 158 ++++++++++++++++ 6 files changed, 577 insertions(+) create mode 100644 internal/registries/inputs.go create mode 100644 internal/registries/inputs_test.go create mode 100644 internal/server/add_from_registry.go create mode 100644 internal/server/add_from_registry_test.go diff --git a/internal/registries/inputs.go b/internal/registries/inputs.go new file mode 100644 index 00000000..386369c7 --- /dev/null +++ b/internal/registries/inputs.go @@ -0,0 +1,91 @@ +package registries + +import ( + "regexp" + "sort" + "strings" +) + +// placeholderPattern matches shell-style env placeholders in an install command +// or URL: ${VAR}, ${VAR:-default}, or a bare $VAR. The captured group is the +// variable name (uppercase letters, digits, and underscores, not starting with +// a digit — the conventional env-var shape). +var placeholderPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(?::[^}]*)?\}|\$([A-Za-z_][A-Za-z0-9_]*)`) + +// DetectRequiredInputs returns the env vars / keys a server needs before it can +// run (FR-003 plumbing). It is best-effort and combines two sources: +// +// (a) any RequiredInputs already declared on the entry (e.g. from a registry +// payload that surfaced them explicitly), and +// (b) a heuristic scan of the install command and URL for ${VAR} / $VAR +// placeholders (decision O1 — no rich per-registry schema in this spec). +// +// Results are de-duplicated by Name and returned in a stable (sorted) order so +// the same entry always yields the same list across surfaces (CN-004). +func DetectRequiredInputs(entry *ServerEntry) []RequiredInput { + if entry == nil { + return nil + } + + byName := make(map[string]RequiredInput) + order := []string{} + add := func(in RequiredInput) { + if in.Name == "" { + return + } + if existing, ok := byName[in.Name]; ok { + // Prefer the richer declaration (keep a description / secret flag + // if the explicit entry provided one). + if existing.Description == "" && in.Description != "" { + existing.Description = in.Description + } + existing.Secret = existing.Secret || in.Secret + byName[in.Name] = existing + return + } + byName[in.Name] = in + order = append(order, in.Name) + } + + // (a) explicit declarations win first so their metadata is preserved. + for _, in := range entry.RequiredInputs { + add(in) + } + + // (b) heuristic placeholder scan over install command and URL. + for _, src := range []string{entry.InstallCmd, entry.URL, entry.ConnectURL} { + for _, m := range placeholderPattern.FindAllStringSubmatch(src, -1) { + name := m[1] + if name == "" { + name = m[2] + } + add(RequiredInput{ + Name: name, + Secret: looksSecret(name), + }) + } + } + + if len(order) == 0 { + return nil + } + + sort.Strings(order) + out := make([]RequiredInput, 0, len(order)) + for _, name := range order { + out = append(out, byName[name]) + } + return out +} + +// looksSecret guesses whether an env var holds a credential, so surfaces can +// mask it. Conservative substring match on the conventional secret-ish words. +func looksSecret(name string) bool { + upper := strings.ToUpper(name) + for _, kw := range []string{"TOKEN", "KEY", "SECRET", "PASSWORD", "PASS", "CREDENTIAL", "AUTH"} { + if strings.Contains(upper, kw) { + return true + } + } + return false +} diff --git a/internal/registries/inputs_test.go b/internal/registries/inputs_test.go new file mode 100644 index 00000000..477124ed --- /dev/null +++ b/internal/registries/inputs_test.go @@ -0,0 +1,57 @@ +package registries + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetectRequiredInputs_PlaceholderScan(t *testing.T) { + entry := &ServerEntry{ + InstallCmd: "npx server --token ${GITHUB_TOKEN} --db $DATABASE_URL", + } + got := DetectRequiredInputs(entry) + require.Len(t, got, 2) + // Sorted, stable order. + assert.Equal(t, "DATABASE_URL", got[0].Name) + assert.Equal(t, "GITHUB_TOKEN", got[1].Name) + // Token-ish names are flagged secret for masking. + assert.True(t, got[1].Secret) +} + +func TestDetectRequiredInputs_BraceDefaultStripped(t *testing.T) { + entry := &ServerEntry{InstallCmd: "run ${API_KEY:-fallback}"} + got := DetectRequiredInputs(entry) + require.Len(t, got, 1) + assert.Equal(t, "API_KEY", got[0].Name) +} + +func TestDetectRequiredInputs_ExplicitMergedWithHeuristic(t *testing.T) { + entry := &ServerEntry{ + InstallCmd: "run ${GITHUB_TOKEN}", + RequiredInputs: []RequiredInput{{Name: "GITHUB_TOKEN", Description: "GitHub PAT", Secret: true}}, + } + got := DetectRequiredInputs(entry) + require.Len(t, got, 1, "explicit + heuristic dup must collapse to one") + assert.Equal(t, "GitHub PAT", got[0].Description, "explicit metadata preserved") +} + +func TestDetectRequiredInputs_None(t *testing.T) { + assert.Nil(t, DetectRequiredInputs(&ServerEntry{InstallCmd: "npx plain-server"})) + assert.Nil(t, DetectRequiredInputs(nil)) +} + +func TestFindServerByIDIn(t *testing.T) { + servers := []ServerEntry{{ID: "a"}, {ID: "b"}, {ID: "c"}} + + got, err := findServerByIDIn(servers, "b") + require.NoError(t, err) + assert.Equal(t, "b", got.ID) + + _, err = findServerByIDIn(servers, "missing") + assert.ErrorIs(t, err, ErrServerNotFound) + + _, err = findServerByIDIn(nil, "a") + assert.ErrorIs(t, err, ErrServerNotFound) +} diff --git a/internal/registries/search.go b/internal/registries/search.go index 8d7373b4..ad757b04 100644 --- a/internal/registries/search.go +++ b/internal/registries/search.go @@ -3,6 +3,7 @@ package registries import ( "context" "encoding/json" + "errors" "fmt" "net/http" "regexp" @@ -12,6 +13,19 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/experiments" ) +// Sentinel errors for single-server lookup. Surfaces map these to stable error +// codes (registry_not_found / server_not_found) for cross-surface consistency +// (CN-004). +var ( + ErrRegistryNotFound = errors.New("registry not found") + ErrServerNotFound = errors.New("server not found in registry") +) + +// findServerByIDLimit caps how many servers FindServerByID fetches before +// matching. Most registries return far fewer; the resilience phase (US4) can +// refine pagination if a target sits beyond this window. +const findServerByIDLimit = 50 + // Constants for repeated strings const ( protocolMCPRun = "custom/mcprun" @@ -74,6 +88,35 @@ func SearchServers(ctx context.Context, registryID, tag, query string, limit int return filtered, nil } +// FindServerByID resolves a single server within a registry by its exact ID. +// It performs a live registry fetch and is the shared resolution path used by +// every add-from-registry surface (CN-001/CN-004). Returns ErrRegistryNotFound +// when registryID does not resolve and ErrServerNotFound when no server matches. +func FindServerByID(ctx context.Context, registryID, serverID string, guesser *experiments.Guesser) (*ServerEntry, error) { + if FindRegistry(registryID) == nil { + return nil, ErrRegistryNotFound + } + + servers, err := SearchServers(ctx, registryID, "", "", findServerByIDLimit, guesser) + if err != nil { + return nil, err + } + + return findServerByIDIn(servers, serverID) +} + +// findServerByIDIn returns the first server whose ID exactly matches serverID. +// Pure (no network) so the not-found path is unit-testable. +func findServerByIDIn(servers []ServerEntry, serverID string) (*ServerEntry, error) { + for i := range servers { + if servers[i].ID == serverID { + match := servers[i] // copy to avoid aliasing the slice backing array + return &match, nil + } + } + return nil, ErrServerNotFound +} + // fetchServers fetches and parses servers from a registry based on its protocol func fetchServers(ctx context.Context, reg *RegistryEntry, guesser *experiments.Guesser) ([]ServerEntry, error) { client := &http.Client{ diff --git a/internal/registries/types.go b/internal/registries/types.go index e9062361..19efc043 100644 --- a/internal/registries/types.go +++ b/internal/registries/types.go @@ -29,4 +29,19 @@ type ServerEntry struct { // Repository detection information RepositoryInfo *experiments.GuessResult `json:"repository_info,omitempty"` // Detected npm/pypi package info + + // RequiredInputs are env vars / keys the user must supply before the server + // can run (FR-003 plumbing). Best-effort: populated either from explicit + // registry payload fields or via a heuristic scan of the install command for + // ${VAR} / $VAR placeholders (see DetectRequiredInputs). Empty for most + // servers in this spec — no rich per-registry schema yet (decision O1). + RequiredInputs []RequiredInput `json:"required_inputs,omitempty"` +} + +// RequiredInput declares a single env var / key a server needs before it will +// work. Surfaces use this to prompt the user (FR-003). +type RequiredInput struct { + Name string `json:"name"` // Env var name (e.g. GITHUB_TOKEN) + Description string `json:"description,omitempty"` // Optional human hint + Secret bool `json:"secret,omitempty"` // Mask in UI/logs when true } diff --git a/internal/server/add_from_registry.go b/internal/server/add_from_registry.go new file mode 100644 index 00000000..a251d387 --- /dev/null +++ b/internal/server/add_from_registry.go @@ -0,0 +1,213 @@ +package server + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/registries" +) + +// Keystone of spec 070: a single backend op that turns a registry *reference* +// (registryID + serverID + optional overrides) into a validated, quarantined +// upstream server. Every surface (REST, MCP, CLI) funnels through here so the +// registry-result → config.ServerConfig normalization lives in exactly one +// place (CN-001) and identical input yields an identical persisted config +// (CN-004). The client never sends a config blob — the server re-derives it +// (security decision D1), so a compromised/buggy client cannot smuggle in +// arbitrary command/args or disable quarantine. + +// Stable error codes shared across surfaces. Surfaces translate these to their +// own envelopes (HTTP 400/404, MCP structured error, CLI message) via +// RegistryAddErrorCode so the same failure reads the same way everywhere. +var ( + // ErrNoInstallInfo means the registry entry had neither an install command + // nor a URL, so there is nothing runnable to persist. + ErrNoInstallInfo = errors.New("no_install_info") + // ErrDuplicateName means an upstream server with the target name already exists. + ErrDuplicateName = errors.New("duplicate_name") +) + +// MissingRequiredInputError is returned when the registry entry declares +// required inputs that the request did not supply. It carries the missing +// names so surfaces can tell the user exactly what to provide. +type MissingRequiredInputError struct { + Names []string +} + +func (e *MissingRequiredInputError) Error() string { + return "missing_required_input: " + strings.Join(e.Names, ", ") +} + +// AddFromRegistryRequest is the reference-based input to the keystone op. +type AddFromRegistryRequest struct { + RegistryID string // required — must resolve via registries.FindRegistry + ServerID string // required — resolved via registries.FindServerByID + Name string // optional override; defaults to the entry's name/id + Env map[string]string // optional; satisfies declared RequiredInputs + Enabled *bool // optional; defaults to true when nil +} + +// RegistryAddErrorCode maps an error returned by AddServerFromRegistry (or the +// pure derivation) to its stable cross-surface code, or "" if it is not one of +// the recognized add-from-registry failures. +func RegistryAddErrorCode(err error) string { + switch { + case err == nil: + return "" + case errors.Is(err, registries.ErrRegistryNotFound): + return "registry_not_found" + case errors.Is(err, registries.ErrServerNotFound): + return "server_not_found" + case errors.Is(err, ErrNoInstallInfo): + return "no_install_info" + case errors.Is(err, ErrDuplicateName): + return "duplicate_name" + } + var missing *MissingRequiredInputError + if errors.As(err, &missing) { + return "missing_required_input" + } + return "" +} + +// AddServerFromRegistry resolves the referenced registry server, re-derives a +// validated config.ServerConfig server-side, and persists it quarantined. +func (s *Server) AddServerFromRegistry(ctx context.Context, req *AddFromRegistryRequest) (*config.ServerConfig, error) { + if req == nil { + return nil, errors.New("nil request") + } + + // Shared resolution path (CN-001): same lookup for every surface. Returns + // registries.ErrRegistryNotFound / ErrServerNotFound, which propagate as + // stable codes. A nil guesser is fine — entries carry their own install + // command/URL; repository guessing is a search-time enrichment. + entry, err := registries.FindServerByID(ctx, req.RegistryID, req.ServerID, nil) + if err != nil { + return nil, err + } + + // Quarantine default comes from global config — never from the request + // (CN-002). Fall back to quarantining when config is unavailable (safe default). + quarantineDefault := true + if cfg := s.runtime.Config(); cfg != nil { + quarantineDefault = cfg.DefaultQuarantineForNewServer() + } + + serverCfg, err := buildServerConfigFromEntry(entry, req, quarantineDefault) + if err != nil { + return nil, err + } + + // Persist via the shared add path (duplicate check + storage + runtime sync). + if err := s.AddServer(ctx, serverCfg); err != nil { + if strings.Contains(err.Error(), "already exists") { + return nil, fmt.Errorf("%w: %s", ErrDuplicateName, serverCfg.Name) + } + return nil, err + } + + return serverCfg, nil +} + +// buildServerConfigFromEntry is the pure derivation core: registry entry + +// request overrides + the proxy's quarantine default → a validated +// config.ServerConfig. No network, no storage — fully unit-testable. +func buildServerConfigFromEntry(entry *registries.ServerEntry, req *AddFromRegistryRequest, quarantineDefault bool) (*config.ServerConfig, error) { + if entry == nil { + return nil, ErrNoInstallInfo + } + if req == nil { + req = &AddFromRegistryRequest{} + } + + // Refuse before persisting anything if declared inputs are unmet (lists names). + if missing := missingRequiredInputs(entry, req.Env); len(missing) > 0 { + return nil, &MissingRequiredInputError{Names: missing} + } + + name := req.Name + if name == "" { + name = entry.Name + } + if name == "" { + name = entry.ID + } + + cfg := &config.ServerConfig{ + Name: name, + Quarantined: quarantineDefault, // CN-002: never overridable to false here + Enabled: true, + } + if req.Enabled != nil { + cfg.Enabled = *req.Enabled + } + + // Carry any supplied env (overrides + required-input values). + if len(req.Env) > 0 { + cfg.Env = make(map[string]string, len(req.Env)) + for k, v := range req.Env { + cfg.Env[k] = v + } + } + + // Derive transport: prefer a stdio install command, else an http/remote URL. + installCmd := resolveInstallCmd(entry) + switch { + case installCmd != "": + command, args := parseInstallCommand(installCmd) + if command == "" { + return nil, ErrNoInstallInfo + } + cfg.Protocol = "stdio" + cfg.Command = command + cfg.Args = args + case entry.URL != "": + cfg.Protocol = "http" + cfg.URL = entry.URL + case entry.ConnectURL != "": + cfg.Protocol = "http" + cfg.URL = entry.ConnectURL + default: + return nil, ErrNoInstallInfo + } + + return cfg, nil +} + +// resolveInstallCmd returns the entry's install command, falling back to a +// repository-info-derived npm install command when the entry itself has none. +func resolveInstallCmd(entry *registries.ServerEntry) string { + if entry.InstallCmd != "" { + return entry.InstallCmd + } + if entry.RepositoryInfo != nil && entry.RepositoryInfo.NPM != nil && entry.RepositoryInfo.NPM.Exists { + return entry.RepositoryInfo.NPM.InstallCmd + } + return "" +} + +// parseInstallCommand splits an install command into command + args. Whitespace +// split matches the historical client-side behavior but now runs server-side so +// every surface derives identical command/args (CN-001/CN-004). +func parseInstallCommand(installCmd string) (command string, args []string) { + fields := strings.Fields(installCmd) + if len(fields) == 0 { + return "", nil + } + return fields[0], fields[1:] +} + +// missingRequiredInputs returns the names of declared/detected required inputs +// that env does not satisfy with a non-empty value. +func missingRequiredInputs(entry *registries.ServerEntry, env map[string]string) []string { + var missing []string + for _, in := range registries.DetectRequiredInputs(entry) { + if v, ok := env[in.Name]; !ok || v == "" { + missing = append(missing, in.Name) + } + } + return missing +} diff --git a/internal/server/add_from_registry_test.go b/internal/server/add_from_registry_test.go new file mode 100644 index 00000000..47863574 --- /dev/null +++ b/internal/server/add_from_registry_test.go @@ -0,0 +1,158 @@ +package server + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/registries" +) + +// boolPtr is declared in mcp_annotations_test.go (same package). + +// --- Pure derivation: stdio install command ---------------------------------- + +func TestAddFromRegistry_BuildStdioFromInstallCmd(t *testing.T) { + entry := ®istries.ServerEntry{ + ID: "everything", + Name: "everything", + InstallCmd: "npx -y @modelcontextprotocol/server-everything", + } + + cfg, err := buildServerConfigFromEntry(entry, &AddFromRegistryRequest{ + RegistryID: "pulse", + ServerID: "everything", + }, true) + + require.NoError(t, err) + assert.Equal(t, "everything", cfg.Name) + assert.Equal(t, "stdio", cfg.Protocol) + assert.Equal(t, "npx", cfg.Command) + assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-everything"}, cfg.Args) + assert.Empty(t, cfg.URL) +} + +// --- Pure derivation: http/remote URL ---------------------------------------- + +func TestAddFromRegistry_BuildHTTPFromURL(t *testing.T) { + entry := ®istries.ServerEntry{ + ID: "context7", + Name: "context7", + URL: "https://mcp.context7.com/mcp", + } + + cfg, err := buildServerConfigFromEntry(entry, &AddFromRegistryRequest{ + RegistryID: "pulse", + ServerID: "context7", + }, true) + + require.NoError(t, err) + assert.Equal(t, "http", cfg.Protocol) + assert.Equal(t, "https://mcp.context7.com/mcp", cfg.URL) + assert.Empty(t, cfg.Command) +} + +// --- Quarantine-by-default (CN-002): client cannot opt out ------------------- + +func TestAddFromRegistry_QuarantineFollowsGlobalDefault(t *testing.T) { + entry := ®istries.ServerEntry{ID: "x", Name: "x", InstallCmd: "npx x"} + + // Global quarantine ON → derived server is quarantined. + on, err := buildServerConfigFromEntry(entry, &AddFromRegistryRequest{}, true) + require.NoError(t, err) + assert.True(t, on.Quarantined, "must quarantine when global default is on") + + // Global quarantine OFF → respects the global default (there is no request + // field to force quarantine false on this path, so it always mirrors the + // global setting). + off, err := buildServerConfigFromEntry(entry, &AddFromRegistryRequest{}, false) + require.NoError(t, err) + assert.False(t, off.Quarantined) +} + +// --- Refusal: nothing runnable ----------------------------------------------- + +func TestAddFromRegistry_NoInstallInfo(t *testing.T) { + entry := ®istries.ServerEntry{ID: "broken", Name: "broken"} // no cmd, no url + + cfg, err := buildServerConfigFromEntry(entry, &AddFromRegistryRequest{}, true) + + require.Error(t, err) + assert.Nil(t, cfg) + assert.True(t, errors.Is(err, ErrNoInstallInfo)) + assert.Equal(t, "no_install_info", RegistryAddErrorCode(err)) +} + +// --- Refusal: required input missing, then satisfied ------------------------- + +func TestAddFromRegistry_MissingRequiredInput(t *testing.T) { + entry := ®istries.ServerEntry{ + ID: "gh", + Name: "gh", + InstallCmd: "npx github-mcp --token ${GITHUB_TOKEN}", + } + + // Missing → refusal that names the variable. + _, err := buildServerConfigFromEntry(entry, &AddFromRegistryRequest{}, true) + require.Error(t, err) + assert.Equal(t, "missing_required_input", RegistryAddErrorCode(err)) + var missing *MissingRequiredInputError + require.True(t, errors.As(err, &missing)) + assert.Equal(t, []string{"GITHUB_TOKEN"}, missing.Names) + + // Supplied via env → accepted, env carried onto the config. + cfg, err := buildServerConfigFromEntry(entry, &AddFromRegistryRequest{ + Env: map[string]string{"GITHUB_TOKEN": "ghp_x"}, + }, true) + require.NoError(t, err) + assert.Equal(t, "ghp_x", cfg.Env["GITHUB_TOKEN"]) +} + +// --- Name override + enabled default ----------------------------------------- + +func TestAddFromRegistry_NameOverrideAndEnabledDefault(t *testing.T) { + entry := ®istries.ServerEntry{ID: "id1", Name: "proposed", InstallCmd: "npx z"} + + // Default name = entry.Name, Enabled defaults to true. + def, err := buildServerConfigFromEntry(entry, &AddFromRegistryRequest{}, true) + require.NoError(t, err) + assert.Equal(t, "proposed", def.Name) + assert.True(t, def.Enabled) + + // Override name + explicit disable. + ov, err := buildServerConfigFromEntry(entry, &AddFromRegistryRequest{ + Name: "myname", + Enabled: boolPtr(false), + }, true) + require.NoError(t, err) + assert.Equal(t, "myname", ov.Name) + assert.False(t, ov.Enabled) +} + +// --- Orchestrator refusal: registry not found (no network) ------------------- + +func TestAddFromRegistry_RegistryNotFound(t *testing.T) { + s := &Server{} + cfg, err := s.AddServerFromRegistry(context.Background(), &AddFromRegistryRequest{ + RegistryID: "does-not-exist-zzz", + ServerID: "whatever", + }) + require.Error(t, err) + assert.Nil(t, cfg) + assert.Equal(t, "registry_not_found", RegistryAddErrorCode(err)) +} + +// --- Error-code mapper ------------------------------------------------------- + +func TestRegistryAddErrorCode(t *testing.T) { + assert.Equal(t, "", RegistryAddErrorCode(nil)) + assert.Equal(t, "registry_not_found", RegistryAddErrorCode(registries.ErrRegistryNotFound)) + assert.Equal(t, "server_not_found", RegistryAddErrorCode(registries.ErrServerNotFound)) + assert.Equal(t, "no_install_info", RegistryAddErrorCode(ErrNoInstallInfo)) + assert.Equal(t, "duplicate_name", RegistryAddErrorCode(ErrDuplicateName)) + assert.Equal(t, "missing_required_input", RegistryAddErrorCode(&MissingRequiredInputError{Names: []string{"K"}})) + assert.Equal(t, "", RegistryAddErrorCode(errors.New("some other error"))) +} From 4a53d534b17f65b45d31dd7370376d556ff82180 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 31 May 2026 19:45:15 +0300 Subject: [PATCH 05/23] =?UTF-8?q?feat(070):=20Web=20UI=20one-flow=20regist?= =?UTF-8?q?ry=20search=E2=86=92add=20(T015-T017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repoint the registry "Add to MCP" button at the backend keystone instead of parsing install_cmd client-side (CN-001 / issue #483 class of bug): - T015: new api.addServerFromRegistry(registryId, serverId, {name,env,enabled}) POSTs to /api/v1/registries/{id}/servers/{serverId}/add and surfaces the structured cross-surface error (code + missing_inputs). Removes the old addServerFromRepository install_cmd.split path. - T016: required-input prompt dialog in Repositories.vue — opens on a missing_required_input response, renders one field per missing input (rich decl from search required_inputs[] when present, else bare names), blocks Add until all are filled, resubmits as env. data-test hooks added across the search → add flow. - T017: e2e/playwright/registry-add.spec.ts (search→Add→quarantined + prompt path); pending the live REST route to run green. - types: RequiredInput + RepositoryServer.required_inputs[]. Verified: 5 vitest unit tests green, vue-tsc clean, vite build green. Co-Authored-By: Paperclip --- e2e/playwright/registry-add.spec.ts | 124 ++++++++++++++++++++ frontend/src/services/api.ts | 102 +++++++++++----- frontend/src/types/api.ts | 11 ++ frontend/src/views/Repositories.vue | 142 +++++++++++++++++++++-- frontend/tests/unit/registry-add.spec.ts | 110 ++++++++++++++++++ 5 files changed, 450 insertions(+), 39 deletions(-) create mode 100644 e2e/playwright/registry-add.spec.ts create mode 100644 frontend/tests/unit/registry-add.spec.ts diff --git a/e2e/playwright/registry-add.spec.ts b/e2e/playwright/registry-add.spec.ts new file mode 100644 index 00000000..b44f5288 --- /dev/null +++ b/e2e/playwright/registry-add.spec.ts @@ -0,0 +1,124 @@ +import { test, expect, Page } from '@playwright/test'; + +/** + * Spec 070 (T017) — Web UI one-flow search → Add → quarantined. + * + * Verifies the registry "Add to MCP" button goes through the backend keystone + * (POST /api/v1/registries/{registryId}/servers/{serverId}/add → AddServerFromRegistry) + * instead of the old client-side install_cmd parsing, and that a server + * declaring required inputs prompts the user before adding. + * + * Requires the T014 REST route (MCP-765 backend dependency) to be live. + * + * Environment variables: + * - MCPPROXY_URL: base URL of a running mcpproxy with Web UI (e.g. http://127.0.0.1:18081) + * - MCPPROXY_API_KEY: API key (default: uitest) + * - REGISTRY_ID: registry to browse (default: first registry returned) + * - SEARCH_QUERY: search term that returns at least one addable server (default: "") + * - REQUIRED_SERVER_ID: optional — a serverId in REGISTRY_ID that declares a required input; + * enables the prompt-flow test. Skipped when unset. + * - REQUIRED_INPUT_NAME:the input name to fill for REQUIRED_SERVER_ID (default: detected from card). + */ + +const MCPPROXY_URL = process.env.MCPPROXY_URL; +const API_KEY = process.env.MCPPROXY_API_KEY || 'uitest'; +const REGISTRY_ID = process.env.REGISTRY_ID || ''; +const SEARCH_QUERY = process.env.SEARCH_QUERY || ''; + +if (!MCPPROXY_URL) { + throw new Error('MCPPROXY_URL environment variable is required'); +} + +const api = async (request: any, method: string, path: string) => { + const res = await request.fetch(`${MCPPROXY_URL}${path}`, { + method, + headers: { 'X-API-Key': API_KEY, 'Content-Type': 'application/json' }, + }); + return res; +}; + +async function openRepositories(page: Page) { + await page.goto(`${MCPPROXY_URL}/ui/?apikey=${encodeURIComponent(API_KEY)}#/repositories`); + await page.waitForLoadState('domcontentloaded'); // never networkidle — SSE keeps the channel open + await expect(page.locator('[data-test="registry-select"]')).toBeVisible(); +} + +async function selectRegistryAndSearch(page: Page) { + const select = page.locator('[data-test="registry-select"]'); + if (REGISTRY_ID) { + await select.selectOption(REGISTRY_ID); + } else { + // Pick the first non-placeholder option. + const value = await select.locator('option:not([disabled])').first().getAttribute('value'); + await select.selectOption(value!); + } + await page.locator('[data-test="registry-search-input"]').fill(SEARCH_QUERY); + await page.locator('[data-test="registry-search-button"]').click(); + // Wait for at least one result card. + await expect(page.locator('[data-test^="registry-server-"]').first()).toBeVisible({ timeout: 15000 }); +} + +test.describe('Registry one-flow add (Spec 070)', () => { + test('search → Add (no required input) → server appears quarantined', async ({ page, request }) => { + await openRepositories(page); + await selectRegistryAndSearch(page); + + // Add the first server without required inputs (no warning badge). + const card = page + .locator('[data-test^="registry-server-"]') + .filter({ hasNot: page.locator('[data-test^="registry-requires-input-"]') }) + .first(); + await expect(card).toBeVisible(); + + const serverId = (await card.getAttribute('data-test'))!.replace('registry-server-', ''); + await card.locator(`[data-test="registry-add-${serverId}"]`).click(); + + // Success toast confirms the add (and that no prompt was required). + await expect(page.locator('[data-test="registry-add-success"]')).toBeVisible({ timeout: 15000 }); + + // The added server is present AND quarantined (backend forced it — CN-002). + const res = await api(request, 'GET', '/api/v1/servers'); + expect(res.ok()).toBeTruthy(); + const body = await res.json(); + const servers = body.data?.servers ?? body.servers ?? []; + expect(servers.length).toBeGreaterThan(0); + const added = servers.find((s: any) => (s.quarantined ?? s.health?.admin_state === 'quarantined')); + expect(added, 'at least one added server should be quarantined').toBeTruthy(); + }); + + test('search → Add server that requires input → prompt blocks until provided → quarantined', async ({ page, request }) => { + const requiredServerId = process.env.REQUIRED_SERVER_ID; + test.skip(!requiredServerId, 'set REQUIRED_SERVER_ID to a registry server that declares a required input'); + + await openRepositories(page); + await selectRegistryAndSearch(page); + + const card = page.locator(`[data-test="registry-server-${requiredServerId}"]`); + await expect(card).toBeVisible(); + await page.locator(`[data-test="registry-add-${requiredServerId}"]`).click(); + + // The required-input dialog opens; Add is blocked until the value is filled. + const dialog = page.locator('[data-test="registry-required-input-dialog"]'); + await expect(dialog).toBeVisible(); + const submit = dialog.locator('[data-test="registry-input-submit"]'); + await expect(submit).toBeDisabled(); + + const inputName = process.env.REQUIRED_INPUT_NAME; + const inputField = inputName + ? dialog.locator(`[data-test="registry-input-${inputName}"]`) + : dialog.locator('[data-test^="registry-input-"]').first(); + await inputField.fill('test-value-123'); + await expect(submit).toBeEnabled(); + await submit.click(); + + await expect(page.locator('[data-test="registry-add-success"]')).toBeVisible({ timeout: 15000 }); + await expect(dialog).toBeHidden(); + + // Verify the env value persisted on the (quarantined) server. + const res = await api(request, 'GET', '/api/v1/servers'); + const body = await res.json(); + const servers = body.data?.servers ?? body.servers ?? []; + const added = servers.find((s: any) => s.name === requiredServerId || s.env); + expect(added).toBeTruthy(); + }); +}); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 3b60fed0..0fc3370b 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,4 +1,4 @@ -import type { APIResponse, Server, Tool, ToolApproval, SearchResult, StatusUpdate, SecretRef, MigrationAnalysis, ConfigSecretsResponse, GetToolCallsResponse, GetToolCallDetailResponse, GetServerToolCallsResponse, GetConfigResponse, ValidateConfigResponse, ConfigApplyResult, ServerTokenMetrics, GetRegistriesResponse, SearchRegistryServersResponse, RepositoryServer, GetSessionsResponse, GetSessionDetailResponse, InfoResponse, ActivityListResponse, ActivityDetailResponse, ActivitySummaryResponse, ImportResponse, AgentTokenInfo, CreateAgentTokenRequest, CreateAgentTokenResponse, RoutingInfo, ConnectStatusResponse, ConnectResult, OnboardingStateResponse, OnboardingMarkRequest, DiagnosticFixResponse, GlobalToolsResponse } from '@/types' +import type { APIResponse, Server, Tool, ToolApproval, SearchResult, StatusUpdate, SecretRef, MigrationAnalysis, ConfigSecretsResponse, GetToolCallsResponse, GetToolCallDetailResponse, GetServerToolCallsResponse, GetConfigResponse, ValidateConfigResponse, ConfigApplyResult, ServerTokenMetrics, GetRegistriesResponse, SearchRegistryServersResponse, GetSessionsResponse, GetSessionDetailResponse, InfoResponse, ActivityListResponse, ActivityDetailResponse, ActivitySummaryResponse, ImportResponse, AgentTokenInfo, CreateAgentTokenRequest, CreateAgentTokenResponse, RoutingInfo, ConnectStatusResponse, ConnectResult, OnboardingStateResponse, OnboardingMarkRequest, DiagnosticFixResponse, GlobalToolsResponse } from '@/types' // Event types for API service export interface APIAuthEvent { @@ -9,6 +9,30 @@ export interface APIAuthEvent { type APIEventListener = (event: APIAuthEvent) => void +// Spec 070: result of the reference-based add-from-registry flow. Unlike the +// generic request() helper (which collapses errors to a single message), this +// carries the stable cross-surface error `code` and the missing-input names so +// the Web UI can drive the required-input prompt without re-parsing strings. +export interface AddedServerSummary { + name: string + protocol?: string + command?: string + args?: string[] + url?: string + quarantined?: boolean +} + +export interface AddFromRegistryResult { + success: boolean + server?: AddedServerSummary + error?: string + // Stable cross-surface code: missing_required_input | no_install_info | + // duplicate_name | registry_not_found | server_not_found + code?: string + // Names of unmet required inputs; present when code === 'missing_required_input'. + missingInputs?: string[] +} + class APIService { private baseUrl = '' private apiKey = '' @@ -643,38 +667,56 @@ class APIService { return this.request(url) } - async addServerFromRepository(server: RepositoryServer): Promise> { - // Use the upstream_servers tool to add the server - const args: Record = { - operation: 'add', - name: server.id, - enabled: true, - protocol: 'stdio' - } + // Spec 070 (CN-001): add a server to upstream by *reference* — the server + // re-derives and validates the config from the registry entry. The client no + // longer splits install_cmd / chooses protocol (that client-side parsing was + // the source of issue #483 and let a buggy client smuggle in arbitrary + // command/args). All add surfaces (REST/MCP/CLI) funnel through the same + // backend keystone (AddServerFromRegistry), so identical input → identical + // persisted, quarantined config (CN-004). + async addServerFromRegistry( + registryId: string, + serverId: string, + opts?: { name?: string; enabled?: boolean; env?: Record } + ): Promise { + const url = `/api/v1/registries/${encodeURIComponent(registryId)}/servers/${encodeURIComponent(serverId)}/add` + + const body: Record = {} + if (opts?.name) body.name = opts.name + if (opts?.enabled !== undefined) body.enabled = opts.enabled + if (opts?.env && Object.keys(opts.env).length > 0) body.env = opts.env + + const headers: Record = { 'Content-Type': 'application/json' } + if (this.apiKey) headers['X-API-Key'] = this.apiKey + + try { + const response = await fetch(`${this.baseUrl}${url}`, { + method: 'POST', + headers, + body: JSON.stringify(body) + }) + + const payload: any = await response.json().catch(() => ({})) + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + this.emitAuthError(payload?.error || `HTTP ${response.status}`, response.status) + } + return { + success: false, + error: payload?.error || `HTTP ${response.status}: ${response.statusText}`, + code: payload?.code, + missingInputs: payload?.missing_inputs + } + } - // Determine command and args from install_cmd or connect_url. - // NB: backend contracts.RepositoryServer serialises these as snake_case - // (install_cmd, connect_url). Reading the wrong casing here is the - // second half of issue #483 — for a stdio entry the install command - // was silently undefined and the call fell through to "neither url nor - // command supplied", surfaced as "Either 'url' or 'command' parameter - // is required". - if (server.install_cmd) { - const parts = server.install_cmd.split(' ').filter(Boolean) - args.command = parts[0] - if (parts.length > 1) { - args.args_json = JSON.stringify(parts.slice(1)) + return { success: true, server: payload?.data?.server } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' } - } else if (server.url) { - // Remote server with HTTP protocol - args.protocol = 'http' - args.url = server.url - } else if (server.connect_url) { - args.protocol = 'http' - args.url = server.connect_url } - - return this.callTool('upstream_servers', args) } // Info endpoint (version and update information) diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 50e0c76a..6a341f46 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -525,6 +525,16 @@ export interface RepositoryInfo { // Future: pypi, docker_hub, etc. } +// RequiredInput declares an env var / key a server needs before it can run. +// Spec 070: detected server-side (explicit registry fields + ${VAR} heuristic) +// and used by the Web UI to prompt the user before adding. Mirrors +// registries.RequiredInput. +export interface RequiredInput { + name: string + description?: string + secret?: boolean +} + export interface RepositoryServer { id: string name: string @@ -537,6 +547,7 @@ export interface RepositoryServer { createdAt?: string registry?: string // Which registry this came from repository_info?: RepositoryInfo // Detected package info + required_inputs?: RequiredInput[] // Spec 070: env/keys the user must supply before add } export interface GetRegistriesResponse { diff --git a/frontend/src/views/Repositories.vue b/frontend/src/views/Repositories.vue index 86874bf5..8e483241 100644 --- a/frontend/src/views/Repositories.vue +++ b/frontend/src/views/Repositories.vue @@ -20,6 +20,7 @@ + + + +
+ {{ error }} +
+ + + + + + + -
+
@@ -218,7 +282,7 @@ import { ref, computed, onMounted } from 'vue' import api from '@/services/api' import CollapsibleHintsPanel from '@/components/CollapsibleHintsPanel.vue' import type { Hint } from '@/components/CollapsibleHintsPanel.vue' -import type { Registry, RepositoryServer } from '@/types' +import type { Registry, RepositoryServer, RequiredInput } from '@/types' // State const registries = ref([]) @@ -232,6 +296,11 @@ const addingServerId = ref(null) const showSuccessToast = ref(false) const successMessage = ref('') +// Required-input prompt state (Spec 070, T016) +const promptServer = ref(null) +const promptInputs = ref([]) +const promptValues = ref>({}) + let searchDebounceTimer: ReturnType | null = null // Computed @@ -239,6 +308,13 @@ const selectedRegistryInfo = computed(() => { return registries.value.find(r => r.id === selectedRegistry.value) }) +const showPrompt = computed(() => promptServer.value !== null) + +// Add is blocked until every prompted input has a non-empty value. +const promptComplete = computed(() => + promptInputs.value.every(i => (promptValues.value[i.name] || '').trim() !== '') +) + const repositoriesHints = computed(() => { return [ { @@ -358,17 +434,35 @@ function handleSearchInput() { }, 500) } -async function addServer(server: RepositoryServer) { +// Add a server by reference (Spec 070, T015/T016). The server re-derives the +// config from the registry entry — no client-side install_cmd parsing. When the +// entry declares required inputs the backend returns `missing_required_input` +// with the missing names; we open a prompt, collect values, and resubmit as env. +async function addServer(server: RepositoryServer, env?: Record) { + if (!server.registry) { + error.value = 'Cannot add: server is missing its registry id.' + return + } + addingServerId.value = server.id error.value = null try { - const response = await api.addServerFromRepository(server) - if (response.success) { - showToast(`Server "${server.name}" added successfully!`) - } else { - error.value = response.error || 'Failed to add server' + const result = await api.addServerFromRegistry(server.registry, server.id, env ? { env } : undefined) + + if (result.success) { + closePrompt() + const name = result.server?.name || server.name + showToast(`Added "${name}" — quarantined. Approve it on the Servers page to enable.`) + return + } + + if (result.code === 'missing_required_input') { + openPrompt(server, result.missingInputs || []) + return } + + error.value = result.error || 'Failed to add server' } catch (err) { error.value = 'Failed to add server: ' + (err as Error).message } finally { @@ -376,6 +470,36 @@ async function addServer(server: RepositoryServer) { } } +// Open the required-input prompt. Prefer the rich declarations carried on the +// search result (name + description + secret); fall back to bare names from the +// backend's missing_required_input error when the search response omitted them. +function openPrompt(server: RepositoryServer, missingNames: string[]) { + const declared = server.required_inputs || [] + const inputs: RequiredInput[] = missingNames.length > 0 + ? missingNames.map(name => declared.find(d => d.name === name) || { name }) + : declared + + promptServer.value = server + promptInputs.value = inputs + promptValues.value = Object.fromEntries(inputs.map(i => [i.name, ''])) +} + +function submitPrompt() { + if (!promptServer.value || !promptComplete.value) return + // Trim values; resubmit through the same add path with the collected env. + const env: Record = {} + for (const input of promptInputs.value) { + env[input.name] = (promptValues.value[input.name] || '').trim() + } + addServer(promptServer.value, env) +} + +function closePrompt() { + promptServer.value = null + promptInputs.value = [] + promptValues.value = {} +} + function copyToClipboard(text: string) { navigator.clipboard.writeText(text) showToast('Installation command copied to clipboard!') diff --git a/frontend/tests/unit/registry-add.spec.ts b/frontend/tests/unit/registry-add.spec.ts new file mode 100644 index 00000000..7c996bbf --- /dev/null +++ b/frontend/tests/unit/registry-add.spec.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import api from '@/services/api' + +// Spec 070 (T015): the Web UI adds a server by *reference* through the new REST +// endpoint. The client must NOT split install_cmd or choose a protocol anymore; +// it posts (registryId, serverId, optional name/enabled/env) and surfaces the +// structured cross-surface error so the UI can drive the required-input prompt. + +describe('api.addServerFromRegistry', () => { + beforeEach(() => { + api.setAPIKey('test-key') + }) + + afterEach(() => { + vi.restoreAllMocks() + api.clearAPIKey() + }) + + it('POSTs to the reference-based add endpoint with env and returns the added server', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + success: true, + data: { server: { name: 'github', protocol: 'stdio', quarantined: true } }, + request_id: 'req-1' + }) + }) + vi.stubGlobal('fetch', fetchMock) + + const result = await api.addServerFromRegistry('pulse', 'github-server', { + env: { GITHUB_TOKEN: 'ghp_x' } + }) + + expect(result.success).toBe(true) + expect(result.server?.name).toBe('github') + expect(result.server?.quarantined).toBe(true) + + const [calledUrl, calledInit] = fetchMock.mock.calls[0] + expect(calledUrl).toBe('/api/v1/registries/pulse/servers/github-server/add') + expect(calledInit.method).toBe('POST') + expect((calledInit.headers as Record)['X-API-Key']).toBe('test-key') + expect(JSON.parse(calledInit.body)).toEqual({ env: { GITHUB_TOKEN: 'ghp_x' } }) + }) + + it('does not send an env key when no env is supplied', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: true, data: { server: { name: 'fs' } } }) + }) + vi.stubGlobal('fetch', fetchMock) + + await api.addServerFromRegistry('pulse', 'fs-server') + + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({}) + }) + + it('surfaces missing_required_input with the missing names for the prompt', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => ({ + success: false, + error: 'missing_required_input: GITHUB_TOKEN', + code: 'missing_required_input', + missing_inputs: ['GITHUB_TOKEN'], + request_id: 'req-2' + }) + }) + vi.stubGlobal('fetch', fetchMock) + + const result = await api.addServerFromRegistry('pulse', 'github-server') + + expect(result.success).toBe(false) + expect(result.code).toBe('missing_required_input') + expect(result.missingInputs).toEqual(['GITHUB_TOKEN']) + }) + + it('surfaces duplicate_name / not-found codes as structured errors', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => ({ success: false, error: 'duplicate_name: github', code: 'duplicate_name' }) + }) + vi.stubGlobal('fetch', fetchMock) + + const result = await api.addServerFromRegistry('pulse', 'github-server') + + expect(result.success).toBe(false) + expect(result.code).toBe('duplicate_name') + expect(result.error).toContain('duplicate_name') + }) + + it('url-encodes registry and server ids', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: true, data: { server: { name: 's' } } }) + }) + vi.stubGlobal('fetch', fetchMock) + + await api.addServerFromRegistry('reg/with space', 'srv/id', { name: 'override' }) + + expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/registries/reg%2Fwith%20space/servers/srv%2Fid/add') + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ name: 'override' }) + }) +}) From c4c2aab4878d99571b7771c0e54904a458a6dc52 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 31 May 2026 19:58:16 +0300 Subject: [PATCH 06/23] feat(070): CLI registry list/search/add + REST add-from-registry (US2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of spec 070 — close the discover→add loop on the CLI, the genuine net-new gap. After this a user can: mcpproxy registry list → registry search -r → registry add and the server appears quarantined in `mcpproxy upstream list`. - cmd/mcpproxy/registry_cmd.go: `registry list|search|add` Cobra group. list/search are daemon-first with an in-process fallback; add requires the daemon because the keystone op (config derivation + quarantine) is server-side (CN-001 / decision D1). Legacy `search-servers` retained. - internal/cliclient: ListRegistries, SearchRegistry, AddFromRegistry + client-side RegistryAddError carrying the stable code + missing inputs. - internal/httpapi: POST /api/v1/registries/{id}/servers/{serverId}/add wired to the keystone via a new AddServerFromRegistryRef controller method; code→HTTP status mapping (404 not-found, 400 derivation/dup/missing-input). - internal/server: AddServerFromRegistryRef adapter projecting failures onto the cross-surface contracts.RegistryAddError (names missing --env keys). - internal/contracts: AddFromRegistryRequest/Data/AddedServerSummary + RegistryAddError. Tests: cliclient httptest units; server adapter units; CLI e2e against a running daemon + mock registry (list→search→add→quarantined) and the missing-required-input actionable-error path. Note: the REST add route was pulled forward from T014 because the CLI is a thin REST client and the e2e ("add via running daemon") cannot pass without a server endpoint; the frontend wiring (T015/T016) remains for US1. Related #746 Co-Authored-By: Paperclip --- cmd/mcpproxy/main.go | 1 + cmd/mcpproxy/registry_cmd.go | 381 ++++++++++++++++++++++ internal/cliclient/client.go | 170 ++++++++++ internal/cliclient/registry_test.go | 114 +++++++ internal/contracts/types.go | 42 +++ internal/httpapi/code_exec_test.go | 4 + internal/httpapi/contracts_test.go | 3 + internal/httpapi/security_test.go | 3 + internal/httpapi/server.go | 98 ++++++ internal/server/add_from_registry.go | 40 +++ internal/server/add_from_registry_test.go | 33 ++ internal/server/registry_add_e2e_test.go | 166 ++++++++++ oas/docs.go | 4 +- oas/swagger.yaml | 70 ++++ 14 files changed, 1127 insertions(+), 2 deletions(-) create mode 100644 cmd/mcpproxy/registry_cmd.go create mode 100644 internal/cliclient/registry_test.go create mode 100644 internal/server/registry_add_e2e_test.go diff --git a/cmd/mcpproxy/main.go b/cmd/mcpproxy/main.go index 1240f3ab..69c90b71 100644 --- a/cmd/mcpproxy/main.go +++ b/cmd/mcpproxy/main.go @@ -190,6 +190,7 @@ func main() { // Add commands to root rootCmd.AddCommand(serverCmd) rootCmd.AddCommand(searchCmd) + rootCmd.AddCommand(GetRegistryCommand()) rootCmd.AddCommand(toolsCmd) rootCmd.AddCommand(callCmd) rootCmd.AddCommand(codeCmd) diff --git a/cmd/mcpproxy/registry_cmd.go b/cmd/mcpproxy/registry_cmd.go new file mode 100644 index 00000000..a6e9235c --- /dev/null +++ b/cmd/mcpproxy/registry_cmd.go @@ -0,0 +1,381 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + "go.uber.org/zap" + + clioutput "github.com/smart-mcp-proxy/mcpproxy-go/internal/cli/output" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/experiments" + "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/socket" +) + +// Registry command flags (spec 070). +var ( + registryConfigPath string + registrySearchTag string + registryLimit int + registryAddName string + registryAddEnv []string + registryAddEnabled bool +) + +// GetRegistryCommand builds the `registry` command group (spec 070): a single +// discovery→add flow on the CLI. +// +// - `registry list` / `registry search` are daemon-first with an in-process +// fallback, so discovery works whether or not a daemon is running. +// - `registry add` REQUIRES a running daemon: the keystone add op +// (registry→config derivation + quarantine) lives server-side so identical +// input yields an identical persisted config across every surface and a +// client cannot smuggle in arbitrary command/args (CN-001 / decision D1). +// +// The legacy top-level `search-servers` command is retained unchanged as a +// back-compat alias. +func GetRegistryCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "registry", + Short: "Discover and add MCP servers from registries", + Long: `Discover MCP servers in known registries and add them as upstream servers. + +Typical flow: + mcpproxy registry list # see available registries + mcpproxy registry search weather -r pulse # find a server + mcpproxy registry add pulse weather-mcp # add it (quarantined) + mcpproxy upstream approve weather-mcp # approve once you trust it + +'registry add' talks to the running mcpproxy daemon. 'list' and 'search' use the +daemon when available and otherwise read the registries directly.`, + } + + cmd.PersistentFlags().StringVarP(®istryConfigPath, "config", "c", "", "Path to MCP configuration file") + cmd.AddCommand(newRegistryListCmd(), newRegistrySearchCmd(), newRegistryAddCmd()) + return cmd +} + +func newRegistryListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List available MCP server registries", + RunE: func(_ *cobra.Command, _ []string) error { + ctx, cancel := registryContext() + defer cancel() + + cfg, err := loadRegistryConfig() + if err != nil { + return outputError(clioutput.NewStructuredError(clioutput.ErrCodeConfigNotFound, err.Error()). + WithRecoveryCommand("mcpproxy doctor"), clioutput.ErrCodeConfigNotFound) + } + formatter, err := GetOutputFormatter() + if err != nil { + return err + } + + // Daemon-first. + if shouldUseUpstreamDaemon(cfg.DataDir) { + client := cliclient.NewClient(socket.DetectSocketPath(cfg.DataDir), nil) + if regs, derr := client.ListRegistries(ctx); derr == nil { + return renderRegistries(formatter, regs) + } + // Fall through to in-process on daemon error. + } + + // In-process fallback. + registries.SetRegistriesFromConfig(cfg) + local := registries.ListRegistries() + regs := make([]map[string]interface{}, len(local)) + for i := range local { + regs[i] = map[string]interface{}{ + "id": local[i].ID, + "name": local[i].Name, + "description": local[i].Description, + } + } + return renderRegistries(formatter, regs) + }, + } +} + +func newRegistrySearchCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "search ", + Short: "Search a registry for MCP servers", + Long: `Search a registry for MCP servers matching a query. + +The registry is selected with --registry (-r). Use 'registry list' to see ids. +The printed ID column is what you pass to 'registry add'.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + query := "" + if len(args) > 0 { + query = args[0] + } + registryID, _ := cmd.Flags().GetString("registry") + if registryID == "" { + return fmt.Errorf("--registry is required (use 'mcpproxy registry list' to see available ids)") + } + + ctx, cancel := registryContext() + defer cancel() + + cfg, err := loadRegistryConfig() + if err != nil { + return outputError(clioutput.NewStructuredError(clioutput.ErrCodeConfigNotFound, err.Error()). + WithRecoveryCommand("mcpproxy doctor"), clioutput.ErrCodeConfigNotFound) + } + formatter, err := GetOutputFormatter() + if err != nil { + return err + } + + // Daemon-first. + if shouldUseUpstreamDaemon(cfg.DataDir) { + client := cliclient.NewClient(socket.DetectSocketPath(cfg.DataDir), nil) + if servers, derr := client.SearchRegistry(ctx, registryID, registrySearchTag, query, registryLimit); derr == nil { + return renderRegistryServers(formatter, servers) + } + // Fall through to in-process on daemon error. + } + + // In-process fallback (mirrors the legacy search-servers path). + registries.SetRegistriesFromConfig(cfg) + var guesser *experiments.Guesser + if cfg.CheckServerRepo { + guesser = experiments.NewGuesser(nil, zap.NewNop()) + } + entries, serr := registries.SearchServers(ctx, registryID, registrySearchTag, query, registryLimit, guesser) + if serr != nil { + return outputError(clioutput.NewStructuredError(clioutput.ErrCodeOperationFailed, serr.Error()), clioutput.ErrCodeOperationFailed) + } + servers := make([]map[string]interface{}, len(entries)) + for i := range entries { + installCmd := entries[i].InstallCmd + if installCmd == "" && entries[i].RepositoryInfo != nil && entries[i].RepositoryInfo.NPM != nil { + installCmd = entries[i].RepositoryInfo.NPM.InstallCmd + } + servers[i] = map[string]interface{}{ + "id": entries[i].ID, + "name": entries[i].Name, + "description": entries[i].Description, + "installCmd": installCmd, + "url": entries[i].URL, + } + } + return renderRegistryServers(formatter, servers) + }, + } + cmd.Flags().StringP("registry", "r", "", "Registry id to search (use 'registry list' to see ids)") + cmd.Flags().StringVarP(®istrySearchTag, "tag", "t", "", "Filter servers by tag/category") + cmd.Flags().IntVarP(®istryLimit, "limit", "l", 10, "Maximum number of results to return") + return cmd +} + +func newRegistryAddCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "add ", + Short: "Add a server from a registry as a (quarantined) upstream", + Long: `Add a server discovered in a registry as an upstream server. + +The server is added quarantined by default; approve it once you trust it: + mcpproxy upstream approve + +The daemon re-derives the runnable config (command/args/url) from the registry +entry — you only supply optional overrides. If the server declares required +inputs, supply them with --env KEY=VALUE.`, + Args: cobra.ExactArgs(2), + RunE: func(_ *cobra.Command, args []string) error { + registryID, serverID := args[0], args[1] + + env, err := parseRegistryEnv(registryAddEnv) + if err != nil { + return err + } + + cfg, err := loadRegistryConfig() + if err != nil { + return outputError(clioutput.NewStructuredError(clioutput.ErrCodeConfigNotFound, err.Error()). + WithRecoveryCommand("mcpproxy doctor"), clioutput.ErrCodeConfigNotFound) + } + + // add MUST go through the daemon (keystone op is server-side). + if !shouldUseUpstreamDaemon(cfg.DataDir) { + return outputError(clioutput.NewStructuredError(clioutput.ErrCodeConnectionFailed, + "adding from a registry requires a running mcpproxy daemon"). + WithGuidance("Start the daemon, then retry"). + WithRecoveryCommand("mcpproxy serve"), clioutput.ErrCodeConnectionFailed) + } + + ctx, cancel := registryContext() + defer cancel() + + client := cliclient.NewClient(socket.DetectSocketPath(cfg.DataDir), nil) + enabled := registryAddEnabled + result, err := client.AddFromRegistry(ctx, registryID, serverID, registryAddName, env, &enabled) + if err != nil { + return registryAddErrorOutput(err) + } + + outputFormat := ResolveOutputFormat() + if outputFormat == "json" || outputFormat == "yaml" { + formatter, _ := GetOutputFormatter() + out, _ := formatter.Format(result) + fmt.Println(out) + return nil + } + + fmt.Printf("✅ Added '%s'", result.Name) + if result.Quarantined { + fmt.Printf(" (quarantined — approve with: mcpproxy upstream approve %s)", result.Name) + } + fmt.Println() + return nil + }, + } + cmd.Flags().StringVar(®istryAddName, "name", "", "Override the server name") + cmd.Flags().StringArrayVar(®istryAddEnv, "env", nil, "Set an environment variable (KEY=VALUE); repeatable") + cmd.Flags().BoolVar(®istryAddEnabled, "enabled", true, "Whether the added server is enabled") + return cmd +} + +// registryAddErrorOutput maps a *cliclient.RegistryAddError to a structured CLI +// error. For missing_required_input it names the exact --env keys to supply. +func registryAddErrorOutput(err error) error { + var addErr *cliclient.RegistryAddError + if !errors.As(err, &addErr) { + return outputError(clioutput.NewStructuredError(clioutput.ErrCodeOperationFailed, err.Error()), clioutput.ErrCodeOperationFailed) + } + + switch addErr.Code { + case "missing_required_input": + guidance := "Supply the required input(s) with --env" + if len(addErr.MissingInputs) > 0 { + example := addErr.MissingInputs[0] + guidance = fmt.Sprintf("Provide: %s — e.g. --env %s=", + strings.Join(addErr.MissingInputs, ", "), example) + } + return outputError(clioutput.NewStructuredError(clioutput.ErrCodeInvalidInput, addErr.Message). + WithGuidance(guidance), clioutput.ErrCodeInvalidInput) + case "duplicate_name": + return outputError(clioutput.NewStructuredError(clioutput.ErrCodeOperationFailed, addErr.Message). + WithGuidance("Choose a different name with --name, or remove the existing server"), clioutput.ErrCodeOperationFailed) + case "registry_not_found", "server_not_found": + return outputError(clioutput.NewStructuredError(clioutput.ErrCodeServerNotFound, addErr.Message). + WithGuidance("Check the ids with 'mcpproxy registry list' and 'mcpproxy registry search'"), clioutput.ErrCodeServerNotFound) + default: + return outputError(clioutput.NewStructuredError(clioutput.ErrCodeOperationFailed, addErr.Message), clioutput.ErrCodeOperationFailed) + } +} + +func parseRegistryEnv(pairs []string) (map[string]string, error) { + if len(pairs) == 0 { + return nil, nil + } + env := make(map[string]string, len(pairs)) + for _, e := range pairs { + parts := strings.SplitN(e, "=", 2) + if len(parts) != 2 || parts[0] == "" { + return nil, fmt.Errorf("invalid --env format: %q (expected KEY=VALUE)", e) + } + env[parts[0]] = parts[1] + } + return env, nil +} + +func renderRegistries(formatter clioutput.OutputFormatter, regs []map[string]interface{}) error { + if _, isTable := formatter.(*clioutput.TableFormatter); isTable { + headers := []string{"ID", "NAME", "DESCRIPTION"} + rows := make([][]string, 0, len(regs)) + for _, r := range regs { + rows = append(rows, []string{mapString(r, "id"), mapString(r, "name"), truncateStr(mapString(r, "description"), 60)}) + } + out, err := formatter.FormatTable(headers, rows) + if err != nil { + return err + } + fmt.Print(out) + fmt.Printf("\nFound %d registries. Search one with: mcpproxy registry search -r \n", len(regs)) + return nil + } + out, err := formatter.Format(regs) + if err != nil { + return err + } + fmt.Println(out) + return nil +} + +func renderRegistryServers(formatter clioutput.OutputFormatter, servers []map[string]interface{}) error { + if _, isTable := formatter.(*clioutput.TableFormatter); isTable { + headers := []string{"ID", "NAME", "DESCRIPTION", "INSTALL CMD"} + rows := make([][]string, 0, len(servers)) + for _, s := range servers { + installCmd := mapString(s, "installCmd") + if installCmd == "" { + installCmd = "-" + } + rows = append(rows, []string{mapString(s, "id"), mapString(s, "name"), truncateStr(mapString(s, "description"), 45), installCmd}) + } + out, err := formatter.FormatTable(headers, rows) + if err != nil { + return err + } + fmt.Print(out) + fmt.Printf("\nFound %d servers. Add one with: mcpproxy registry add \n", len(servers)) + return nil + } + out, err := formatter.Format(servers) + if err != nil { + return err + } + fmt.Println(out) + return nil +} + +func mapString(m map[string]interface{}, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func truncateStr(s string, max int) string { + if len(s) > max { + return s[:max-3] + "..." + } + return s +} + +// loadRegistryConfig loads config for the registry commands, honoring the +// command's --config flag and the global --data-dir, falling back to defaults +// so 'list'/'search' still work without a config file. +func loadRegistryConfig() (*config.Config, error) { + var cfg *config.Config + var err error + if registryConfigPath != "" { + cfg, err = config.LoadFromFile(registryConfigPath) + } else { + cfg, err = config.Load() + } + if err != nil { + // Discovery should still work with defaults if no config is present. + cfg = config.DefaultConfig() + } + if dataDir != "" { + cfg.DataDir = dataDir + } + return cfg, nil +} + +func registryContext() (context.Context, context.CancelFunc) { + ctx := reqcontext.WithMetadata(context.Background(), reqcontext.SourceCLI) + return context.WithTimeout(ctx, 30*time.Second) +} diff --git a/internal/cliclient/client.go b/internal/cliclient/client.go index 0f4dbe98..70c9b1ae 100644 --- a/internal/cliclient/client.go +++ b/internal/cliclient/client.go @@ -1692,3 +1692,173 @@ func (c *Client) ApproveTools(ctx context.Context, serverName string, toolNames return apiResp.Data.Approved, nil } + +// ListRegistries returns the MCP server registries known to the daemon +// (spec 070). Mirrors GetServers: GET /api/v1/registries → data.registries. +func (c *Client) ListRegistries(ctx context.Context) ([]map[string]interface{}, error) { + u := c.baseURL + "/api/v1/registries" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + c.prepareRequest(ctx, req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call registries API: %w", err) + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var apiResp struct { + Success bool `json:"success"` + Data struct { + Registries []map[string]interface{} `json:"registries"` + } `json:"data"` + Error string `json:"error"` + RequestID string `json:"request_id"` + } + if err := json.Unmarshal(bodyBytes, &apiResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + if !apiResp.Success { + return nil, parseAPIError(apiResp.Error, apiResp.RequestID) + } + return apiResp.Data.Registries, nil +} + +// SearchRegistry searches the servers in a registry via the daemon (spec 070). +// GET /api/v1/registries/{id}/servers?q=&tag=&limit= → data.servers. +func (c *Client) SearchRegistry(ctx context.Context, registryID, tag, query string, limit int) ([]map[string]interface{}, error) { + u := fmt.Sprintf("%s/api/v1/registries/%s/servers", c.baseURL, url.PathEscape(registryID)) + q := url.Values{} + if query != "" { + q.Set("q", query) + } + if tag != "" { + q.Set("tag", tag) + } + if limit > 0 { + q.Set("limit", fmt.Sprintf("%d", limit)) + } + if encoded := q.Encode(); encoded != "" { + u += "?" + encoded + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + c.prepareRequest(ctx, req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call registry search API: %w", err) + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var apiResp struct { + Success bool `json:"success"` + Data struct { + Servers []map[string]interface{} `json:"servers"` + } `json:"data"` + Error string `json:"error"` + RequestID string `json:"request_id"` + } + if err := json.Unmarshal(bodyBytes, &apiResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + if !apiResp.Success { + return nil, parseAPIError(apiResp.Error, apiResp.RequestID) + } + return apiResp.Data.Servers, nil +} + +// RegistryAddError is the client-side projection of a failed add-from-registry +// (spec 070). It carries the stable cross-surface Code and, for +// missing_required_input, the names of the inputs the user must supply so the +// CLI can name the exact --env keys. +type RegistryAddError struct { + Code string + Message string + MissingInputs []string + RequestID string +} + +func (e *RegistryAddError) Error() string { return e.Message } + +// AddFromRegistry adds an upstream server from a registry reference via the +// daemon (spec 070 keystone). The daemon re-derives the runnable config from +// the registry entry — the client only sends optional overrides. On failure it +// returns a *RegistryAddError carrying the stable code. +func (c *Client) AddFromRegistry(ctx context.Context, registryID, serverID, name string, env map[string]string, enabled *bool) (*contracts.AddedServerSummary, error) { + body := contracts.AddFromRegistryRequest{Name: name, Env: env, Enabled: enabled} + bodyBytes, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + u := fmt.Sprintf("%s/api/v1/registries/%s/servers/%s/add", + c.baseURL, url.PathEscape(registryID), url.PathEscape(serverID)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + c.prepareRequest(ctx, req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call add-from-registry API: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var apiResp struct { + Success bool `json:"success"` + Data *contracts.AddFromRegistryData `json:"data"` + Error string `json:"error"` + Code string `json:"code"` + MissingInputs []string `json:"missing_inputs"` + RequestID string `json:"request_id"` + } + if err := json.Unmarshal(respBytes, &apiResp); err != nil { + return nil, fmt.Errorf("failed to parse response (status %d): %s", resp.StatusCode, string(respBytes)) + } + + if !apiResp.Success || resp.StatusCode != http.StatusOK { + msg := apiResp.Error + if msg == "" { + msg = fmt.Sprintf("API returned status %d", resp.StatusCode) + } + return nil, &RegistryAddError{ + Code: apiResp.Code, + Message: msg, + MissingInputs: apiResp.MissingInputs, + RequestID: apiResp.RequestID, + } + } + if apiResp.Data == nil { + return nil, fmt.Errorf("daemon returned success with no server data") + } + return &apiResp.Data.Server, nil +} diff --git a/internal/cliclient/registry_test.go b/internal/cliclient/registry_test.go new file mode 100644 index 00000000..ec8442d4 --- /dev/null +++ b/internal/cliclient/registry_test.go @@ -0,0 +1,114 @@ +package cliclient_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_ListRegistries(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/registries", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]interface{}{ + "registries": []map[string]interface{}{ + {"id": "pulse", "name": "Pulse"}, + {"id": "smithery", "name": "Smithery"}, + }, + }, + }) + })) + defer server.Close() + + client := cliclient.NewClient(server.URL, nil) + regs, err := client.ListRegistries(context.Background()) + require.NoError(t, err) + require.Len(t, regs, 2) + assert.Equal(t, "pulse", regs[0]["id"]) +} + +func TestClient_SearchRegistry(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/registries/pulse/servers", r.URL.Path) + assert.Equal(t, "weather", r.URL.Query().Get("q")) + assert.Equal(t, "5", r.URL.Query().Get("limit")) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]interface{}{ + "servers": []map[string]interface{}{ + {"id": "weather-mcp", "name": "weather-mcp", "installCmd": "npx weather-mcp"}, + }, + }, + }) + })) + defer server.Close() + + client := cliclient.NewClient(server.URL, nil) + servers, err := client.SearchRegistry(context.Background(), "pulse", "", "weather", 5) + require.NoError(t, err) + require.Len(t, servers, 1) + assert.Equal(t, "weather-mcp", servers[0]["id"]) +} + +func TestClient_AddFromRegistry_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/registries/pulse/servers/weather-mcp/add", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]interface{}{ + "server": map[string]interface{}{ + "name": "weather", + "protocol": "stdio", + "command": "npx", + "args": []string{"weather-mcp"}, + "quarantined": true, + "enabled": true, + }, + }, + }) + })) + defer server.Close() + + client := cliclient.NewClient(server.URL, nil) + got, err := client.AddFromRegistry(context.Background(), "pulse", "weather-mcp", "weather", nil, nil) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "weather", got.Name) + assert.Equal(t, "stdio", got.Protocol) + assert.True(t, got.Quarantined) +} + +func TestClient_AddFromRegistry_MissingRequiredInput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": "missing_required_input: GITHUB_TOKEN", + "code": "missing_required_input", + "missing_inputs": []string{"GITHUB_TOKEN"}, + "request_id": "req-123", + }) + })) + defer server.Close() + + client := cliclient.NewClient(server.URL, nil) + _, err := client.AddFromRegistry(context.Background(), "pulse", "gh", "", nil, nil) + require.Error(t, err) + + var addErr *cliclient.RegistryAddError + require.True(t, errors.As(err, &addErr), "should be a *RegistryAddError") + assert.Equal(t, "missing_required_input", addErr.Code) + assert.Equal(t, []string{"GITHUB_TOKEN"}, addErr.MissingInputs) + assert.Equal(t, "req-123", addErr.RequestID) +} diff --git a/internal/contracts/types.go b/internal/contracts/types.go index b5f2f274..ea3763dd 100644 --- a/internal/contracts/types.go +++ b/internal/contracts/types.go @@ -838,6 +838,48 @@ type SearchRegistryServersResponse struct { Tag string `json:"tag,omitempty"` } +// AddFromRegistryRequest is the optional POST body for adding an upstream from +// a registry reference (spec 070, POST /registries/{id}/servers/{serverId}/add). +// The registry id + server id come from the URL path; this body carries only +// the optional overrides. The client never sends a config blob — the server +// re-derives the runnable config from the registry entry (CN-001 / security +// decision D1), so command/args/url and the quarantine flag cannot be smuggled. +type AddFromRegistryRequest struct { + Name string `json:"name,omitempty"` // optional name override + Env map[string]string `json:"env,omitempty"` // overrides + required-input values + Enabled *bool `json:"enabled,omitempty"` // defaults to true when nil +} + +// AddedServerSummary is the persisted-server view returned on a successful +// add-from-registry. It is intentionally a slim, stable projection of the +// re-derived config.ServerConfig (not the full struct) so the cross-surface +// contract does not leak unrelated config fields. +type AddedServerSummary struct { + Name string `json:"name"` + Protocol string `json:"protocol"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + URL string `json:"url,omitempty"` + Enabled bool `json:"enabled"` + Quarantined bool `json:"quarantined"` +} + +// AddFromRegistryData is the success `data` payload for add-from-registry. +type AddFromRegistryData struct { + Server AddedServerSummary `json:"server"` +} + +// RegistryAddError carries the stable cross-surface failure code for an +// add-from-registry attempt (spec 070 CN-001). Every surface (REST, MCP, CLI) +// reports the same Code so a given failure reads identically everywhere. +// MissingInputs is populated only for code == "missing_required_input" so the +// caller can name the exact --env keys the user must supply (FR-003). +type RegistryAddError struct { + Code string `json:"code"` + Message string `json:"message"` + MissingInputs []string `json:"missing_inputs,omitempty"` +} + // SuccessResponse is the standard success response wrapper for API endpoints. type SuccessResponse struct { Success bool `json:"success"` diff --git a/internal/httpapi/code_exec_test.go b/internal/httpapi/code_exec_test.go index 7256e61d..6d3a9b23 100644 --- a/internal/httpapi/code_exec_test.go +++ b/internal/httpapi/code_exec_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "testing" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" "github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi" @@ -92,6 +93,9 @@ func (m *mockController) ListRegistries() ([]interface{}, error) { return nil, n func (m *mockController) SearchRegistryServers(registryID, tag, query string, limit int) ([]interface{}, error) { return nil, nil } +func (m *mockController) AddServerFromRegistryRef(_ context.Context, _, _, _ string, _ map[string]string, _ *bool) (*config.ServerConfig, *contracts.RegistryAddError, error) { + return nil, nil, nil +} func (m *mockController) GetManagementService() interface{} { return nil } func (m *mockController) GetRuntime() interface{} { return nil } func (m *mockController) GetSessions(limit, offset int) (interface{}, int, error) { return nil, 0, nil } diff --git a/internal/httpapi/contracts_test.go b/internal/httpapi/contracts_test.go index 2eba00b2..f7749404 100644 --- a/internal/httpapi/contracts_test.go +++ b/internal/httpapi/contracts_test.go @@ -308,6 +308,9 @@ func (m *MockServerController) ListRegistries() ([]interface{}, error) { func (m *MockServerController) SearchRegistryServers(_, _, _ string, _ int) ([]interface{}, error) { return []interface{}{}, nil } +func (m *MockServerController) AddServerFromRegistryRef(_ context.Context, _, _, _ string, _ map[string]string, _ *bool) (*config.ServerConfig, *contracts.RegistryAddError, error) { + return nil, nil, nil +} // Version and updates func (m *MockServerController) GetVersionInfo() *updatecheck.VersionInfo { diff --git a/internal/httpapi/security_test.go b/internal/httpapi/security_test.go index 6e100b6f..c0748c17 100644 --- a/internal/httpapi/security_test.go +++ b/internal/httpapi/security_test.go @@ -293,6 +293,9 @@ func (m *baseController) ListRegistries() ([]interface{}, error) { func (m *baseController) SearchRegistryServers(registryID, query, tag string, limit int) ([]interface{}, error) { return nil, nil } +func (m *baseController) AddServerFromRegistryRef(_ context.Context, _, _, _ string, _ map[string]string, _ *bool) (*config.ServerConfig, *contracts.RegistryAddError, error) { + return nil, nil, nil +} func (m *baseController) CallTool(ctx context.Context, toolName string, args map[string]interface{}) (interface{}, error) { return nil, nil } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 17a45dd4..cad43124 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "math" "net/http" "sort" @@ -115,6 +116,11 @@ type ServerController interface { // Registry browsing (Phase 7) ListRegistries() ([]interface{}, error) SearchRegistryServers(registryID, tag, query string, limit int) ([]interface{}, error) + // AddServerFromRegistryRef resolves a registry reference server-side and + // persists it quarantined (spec 070 keystone). On failure it returns a + // stable cross-surface error code (*contracts.RegistryAddError) alongside + // the raw error so the handler can map code → HTTP status. + AddServerFromRegistryRef(ctx context.Context, registryID, serverID, name string, env map[string]string, enabled *bool) (*config.ServerConfig, *contracts.RegistryAddError, error) // Version and updates GetVersionInfo() *updatecheck.VersionInfo @@ -619,6 +625,7 @@ func (s *Server) setupRoutes() { // Registry browsing (Phase 7) r.Get("/registries", s.handleListRegistries) r.Get("/registries/{id}/servers", s.handleSearchRegistryServers) + r.Post("/registries/{id}/servers/{serverId}/add", s.handleAddFromRegistry) // spec 070 keystone add // Activity logging (RFC-003) r.Get("/activity", s.handleListActivity) @@ -4034,6 +4041,97 @@ func (s *Server) handleSearchRegistryServers(w http.ResponseWriter, r *http.Requ s.writeSuccess(w, response) } +// handleAddFromRegistry godoc +// @Summary Add an upstream server from a registry reference +// @Description Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request. +// @Tags registries +// @Accept json +// @Produce json +// @Param id path string true "Registry ID" +// @Param serverId path string true "Server ID within the registry" +// @Param body body contracts.AddFromRegistryRequest false "Optional overrides (name, env, enabled)" +// @Success 200 {object} contracts.SuccessResponse "Server added (quarantined)" +// @Failure 400 {object} contracts.ErrorResponse "no_install_info | missing_required_input | duplicate_name" +// @Failure 404 {object} contracts.ErrorResponse "registry_not_found | server_not_found" +// @Failure 500 {object} contracts.ErrorResponse "Internal server error" +// @Security ApiKeyAuth +// @Security ApiKeyQuery +// @Router /api/v1/registries/{id}/servers/{serverId}/add [post] +func (s *Server) handleAddFromRegistry(w http.ResponseWriter, r *http.Request) { + registryID := chi.URLParam(r, "id") + serverID := chi.URLParam(r, "serverId") + if registryID == "" || serverID == "" { + s.writeError(w, r, http.StatusBadRequest, "registry id and server id are required") + return + } + + // Body is optional: missing/empty body means "no overrides". + var req contracts.AddFromRegistryRequest + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + s.writeError(w, r, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err)) + return + } + } + + logger := s.getRequestLogger(r) + cfg, rerr, err := s.controller.AddServerFromRegistryRef(r.Context(), registryID, serverID, req.Name, req.Env, req.Enabled) + if err != nil { + status := registryAddErrorStatus(rerr.Code) + if status >= http.StatusInternalServerError { + logger.Error("Add from registry failed", "registry", registryID, "server", serverID, "error", err) + } + s.writeRegistryAddError(w, r, status, rerr) + return + } + + s.writeSuccess(w, contracts.AddFromRegistryData{ + Server: contracts.AddedServerSummary{ + Name: cfg.Name, + Protocol: cfg.Protocol, + Command: cfg.Command, + Args: cfg.Args, + URL: cfg.URL, + Enabled: cfg.Enabled, + Quarantined: cfg.Quarantined, + }, + }) +} + +// registryAddErrorStatus maps a stable add-from-registry error code to its HTTP +// status (spec 070 contract). An unknown/empty code is an internal error. +func registryAddErrorStatus(code string) int { + switch code { + case "registry_not_found", "server_not_found": + return http.StatusNotFound + case "no_install_info", "missing_required_input", "duplicate_name": + return http.StatusBadRequest + default: + return http.StatusInternalServerError + } +} + +// writeRegistryAddError writes the structured cross-surface error envelope so +// every surface can read the same stable `code` and (for missing inputs) the +// exact keys to supply. +func (s *Server) writeRegistryAddError(w http.ResponseWriter, r *http.Request, status int, rerr *contracts.RegistryAddError) { + requestID := reqcontext.GetRequestID(r.Context()) + body := struct { + Success bool `json:"success"` + Error string `json:"error"` + Code string `json:"code"` + MissingInputs []string `json:"missing_inputs,omitempty"` + RequestID string `json:"request_id,omitempty"` + }{ + Success: false, + Error: rerr.Message, + Code: rerr.Code, + MissingInputs: rerr.MissingInputs, + RequestID: requestID, + } + s.writeJSON(w, status, body) +} + // Helper functions for type conversion func getString(m map[string]interface{}, key string) string { if val, ok := m[key].(string); ok { diff --git a/internal/server/add_from_registry.go b/internal/server/add_from_registry.go index a251d387..09c2944c 100644 --- a/internal/server/add_from_registry.go +++ b/internal/server/add_from_registry.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" "github.com/smart-mcp-proxy/mcpproxy-go/internal/registries" ) @@ -112,6 +113,45 @@ func (s *Server) AddServerFromRegistry(ctx context.Context, req *AddFromRegistry return serverCfg, nil } +// AddServerFromRegistryRef is the surface-facing adapter over the keystone +// AddServerFromRegistry. It builds the reference request from primitive args +// (so callers across the import graph need not depend on the server-internal +// request type), and on failure projects the typed error into a stable +// cross-surface contracts.RegistryAddError (CN-001) so REST/MCP/CLI report the +// same code. On success the second return is nil. +func (s *Server) AddServerFromRegistryRef(ctx context.Context, registryID, serverID, name string, env map[string]string, enabled *bool) (*config.ServerConfig, *contracts.RegistryAddError, error) { + cfg, err := s.AddServerFromRegistry(ctx, &AddFromRegistryRequest{ + RegistryID: registryID, + ServerID: serverID, + Name: name, + Env: env, + Enabled: enabled, + }) + if err != nil { + return nil, newRegistryAddError(err), err + } + return cfg, nil, nil +} + +// newRegistryAddError projects an add-from-registry failure onto the stable +// cross-surface error contract. Returns nil for a nil error. For a +// missing-required-input failure it carries the offending input names so a +// surface can tell the user exactly which --env keys to supply (FR-003). +func newRegistryAddError(err error) *contracts.RegistryAddError { + if err == nil { + return nil + } + re := &contracts.RegistryAddError{ + Code: RegistryAddErrorCode(err), + Message: err.Error(), + } + var missing *MissingRequiredInputError + if errors.As(err, &missing) { + re.MissingInputs = missing.Names + } + return re +} + // buildServerConfigFromEntry is the pure derivation core: registry entry + // request overrides + the proxy's quarantine default → a validated // config.ServerConfig. No network, no storage — fully unit-testable. diff --git a/internal/server/add_from_registry_test.go b/internal/server/add_from_registry_test.go index 47863574..9b97cb37 100644 --- a/internal/server/add_from_registry_test.go +++ b/internal/server/add_from_registry_test.go @@ -11,6 +11,39 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/registries" ) +// --- Cross-surface error projection (newRegistryAddError) -------------------- + +func TestNewRegistryAddError(t *testing.T) { + assert.Nil(t, newRegistryAddError(nil)) + + missing := newRegistryAddError(&MissingRequiredInputError{Names: []string{"GITHUB_TOKEN", "API_KEY"}}) + require.NotNil(t, missing) + assert.Equal(t, "missing_required_input", missing.Code) + assert.Equal(t, []string{"GITHUB_TOKEN", "API_KEY"}, missing.MissingInputs) + assert.Contains(t, missing.Message, "GITHUB_TOKEN") + + noInfo := newRegistryAddError(ErrNoInstallInfo) + require.NotNil(t, noInfo) + assert.Equal(t, "no_install_info", noInfo.Code) + assert.Empty(t, noInfo.MissingInputs) + + unknown := newRegistryAddError(errors.New("boom")) + require.NotNil(t, unknown) + assert.Equal(t, "", unknown.Code, "unrecognized errors carry an empty stable code") + assert.Equal(t, "boom", unknown.Message) +} + +// --- REST adapter: surfaces the stable code on failure ----------------------- + +func TestAddServerFromRegistryRef_RegistryNotFound(t *testing.T) { + s := &Server{} + cfg, rerr, err := s.AddServerFromRegistryRef(context.Background(), "does-not-exist-zzz", "whatever", "", nil, nil) + require.Error(t, err) + assert.Nil(t, cfg) + require.NotNil(t, rerr) + assert.Equal(t, "registry_not_found", rerr.Code) +} + // boolPtr is declared in mcp_annotations_test.go (same package). // --- Pure derivation: stdio install command ---------------------------------- diff --git a/internal/server/registry_add_e2e_test.go b/internal/server/registry_add_e2e_test.go new file mode 100644 index 00000000..3ae278ff --- /dev/null +++ b/internal/server/registry_add_e2e_test.go @@ -0,0 +1,166 @@ +package server_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRegistryAddCLIE2E exercises the spec-070 CLI MVP end to end against a +// running daemon and a mock registry: list → search → add → assert the server +// shows up quarantined in `upstream list`. The mock registry is an in-process +// httptest server so the test is deterministic and needs no network. +func TestRegistryAddCLIE2E(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + // Mock registry: a default-protocol server list with one stdio server that + // declares no required inputs (so the add succeeds without --env). + const serversJSON = `[ + {"id":"echo-mcp","name":"echo-mcp","description":"Echo server for testing","installCmd":"npx -y echo-mcp"} + ]` + mockReg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(serversJSON)) + })) + defer mockReg.Close() + + tmpDir := filepath.Join("/tmp", "mcpproxy-test-"+t.Name()) + require.NoError(t, os.MkdirAll(tmpDir, 0700)) + defer os.RemoveAll(tmpDir) + + // Build mcpproxy binary. + mcpproxyBin := filepath.Join(tmpDir, binaryName("mcpproxy")) + buildCmd := exec.Command("go", "build", "-o", mcpproxyBin, "./cmd/mcpproxy") + buildCmd.Dir = filepath.Join("..", "..") + out, err := buildCmd.CombinedOutput() + require.NoError(t, err, "Failed to build mcpproxy: %s", string(out)) + + // Config with a custom (default-protocol) registry pointing at the mock. + configPath := filepath.Join(tmpDir, "mcp_config.json") + cfg := `{ + "listen": "127.0.0.1:18085", + "data_dir": "` + tmpDir + `", + "enable_socket": true, + "check_server_repo": false, + "registries": [ + { + "id": "mocktest", + "name": "Mock Test Registry", + "description": "Local test registry", + "url": "` + mockReg.URL + `", + "protocol": "raw/list", + "servers_url": "` + mockReg.URL + `/servers" + } + ], + "mcpServers": [] + }` + require.NoError(t, os.WriteFile(configPath, []byte(cfg), 0600)) + + // Start daemon. + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + daemonCmd := exec.CommandContext(ctx, mcpproxyBin, "serve", "--config", configPath) + daemonCmd.Env = append(os.Environ(), "MCPPROXY_DATA_DIR="+tmpDir) + require.NoError(t, daemonCmd.Start()) + defer func() { _ = daemonCmd.Process.Kill() }() + + require.NoError(t, waitForServerReady("127.0.0.1:18085", tmpDir, 20*time.Second), "Daemon failed to become ready") + + run := func(args ...string) (string, error) { + full := append([]string{}, args...) + full = append(full, "--config", configPath) + c := exec.Command(mcpproxyBin, full...) + c.Env = append(os.Environ(), "MCPPROXY_DATA_DIR="+tmpDir) + o, e := c.CombinedOutput() + return string(o), e + } + + // 1) list → shows the custom registry. + listOut, err := run("registry", "list") + require.NoError(t, err, "registry list failed: %s", listOut) + assert.Contains(t, listOut, "mocktest", "registry list should show the custom registry") + + // 2) search → finds the server. + searchOut, err := run("registry", "search", "echo", "--registry", "mocktest") + require.NoError(t, err, "registry search failed: %s", searchOut) + assert.Contains(t, searchOut, "echo-mcp", "registry search should find the server") + + // 3) add → succeeds and reports quarantined. + addOut, err := run("registry", "add", "mocktest", "echo-mcp") + require.NoError(t, err, "registry add failed: %s", addOut) + assert.Contains(t, strings.ToLower(addOut), "quarantin", "add should report the server is quarantined") + + // 4) upstream list → the added server is present. + upstreamOut, err := run("upstream", "list") + require.NoError(t, err, "upstream list failed: %s", upstreamOut) + assert.Contains(t, upstreamOut, "echo-mcp", "added server should appear in upstream list") +} + +// TestRegistryAddCLIMissingInputE2E verifies the actionable error path: a +// server that declares a required input is refused with missing_required_input +// and the CLI names the --env key to supply. +func TestRegistryAddCLIMissingInputE2E(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + // Install command references ${GITHUB_TOKEN} → detected as a required input. + const serversJSON = `[ + {"id":"gh-mcp","name":"gh-mcp","description":"GitHub server","installCmd":"npx gh-mcp --token ${GITHUB_TOKEN}"} + ]` + mockReg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(serversJSON)) + })) + defer mockReg.Close() + + tmpDir := filepath.Join("/tmp", "mcpproxy-test-"+t.Name()) + require.NoError(t, os.MkdirAll(tmpDir, 0700)) + defer os.RemoveAll(tmpDir) + + mcpproxyBin := filepath.Join(tmpDir, binaryName("mcpproxy")) + buildCmd := exec.Command("go", "build", "-o", mcpproxyBin, "./cmd/mcpproxy") + buildCmd.Dir = filepath.Join("..", "..") + out, err := buildCmd.CombinedOutput() + require.NoError(t, err, "Failed to build mcpproxy: %s", string(out)) + + configPath := filepath.Join(tmpDir, "mcp_config.json") + cfg := `{ + "listen": "127.0.0.1:18086", + "data_dir": "` + tmpDir + `", + "enable_socket": true, + "check_server_repo": false, + "registries": [ + {"id":"mocktest","name":"Mock","description":"d","url":"` + mockReg.URL + `","protocol":"raw/list","servers_url":"` + mockReg.URL + `/servers"} + ], + "mcpServers": [] + }` + require.NoError(t, os.WriteFile(configPath, []byte(cfg), 0600)) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + daemonCmd := exec.CommandContext(ctx, mcpproxyBin, "serve", "--config", configPath) + daemonCmd.Env = append(os.Environ(), "MCPPROXY_DATA_DIR="+tmpDir) + require.NoError(t, daemonCmd.Start()) + defer func() { _ = daemonCmd.Process.Kill() }() + + require.NoError(t, waitForServerReady("127.0.0.1:18086", tmpDir, 20*time.Second), "Daemon failed to become ready") + + // add without the required input → refused, names GITHUB_TOKEN. + c := exec.Command(mcpproxyBin, "registry", "add", "mocktest", "gh-mcp", "--config", configPath) + c.Env = append(os.Environ(), "MCPPROXY_DATA_DIR="+tmpDir) + addOut, err := c.CombinedOutput() + require.Error(t, err, "add should fail when a required input is missing") + assert.Contains(t, string(addOut), "GITHUB_TOKEN", "error should name the missing --env key") +} diff --git a/oas/docs.go b/oas/docs.go index 1a57fce8..3668141c 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -6,10 +6,10 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"config.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_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"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.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.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_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"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.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.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.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.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"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"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"]}}}, + "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"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"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/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"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"]}}}, "openapi": "3.1.0" }` diff --git a/oas/swagger.yaml b/oas/swagger.yaml index 5667853d..8056c575 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -845,6 +845,20 @@ components: - ActivityTypePolicyDecision - ActivityTypeQuarantineChange - ActivityTypeServerChange + contracts.AddFromRegistryRequest: + properties: + enabled: + description: defaults to true when nil + type: boolean + env: + additionalProperties: + type: string + description: overrides + required-input values + type: object + name: + description: optional name override + type: string + type: object contracts.ConfigApplyResult: properties: applied_immediately: @@ -3321,6 +3335,62 @@ paths: summary: Search MCP servers in a registry tags: - registries + /api/v1/registries/{id}/servers/{serverId}/add: + post: + description: Resolves a registry server reference server-side, re-derives a + validated config, and persists it quarantined (spec 070 keystone). The client + never sends a config blob — command/args/url and the quarantine flag are derived + from the registry entry, not the request. + parameters: + - description: Registry ID + in: path + name: id + required: true + schema: + type: string + - description: Server ID within the registry + in: path + name: serverId + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.AddFromRegistryRequest' + description: Optional overrides (name, env, enabled) + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.SuccessResponse' + description: Server added (quarantined) + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: no_install_info | missing_required_input | duplicate_name + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: registry_not_found | server_not_found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Internal server error + security: + - ApiKeyAuth: [] + - ApiKeyQuery: [] + summary: Add an upstream server from a registry reference + tags: + - registries /api/v1/routing: get: description: Get the current routing mode and available MCP endpoints From b73917d77fe36764eaab166ff966073069cafb53 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 31 May 2026 20:12:51 +0300 Subject: [PATCH 07/23] test(070): fix registry-add Playwright spec route + decouple prompt-test registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T017 follow-up after running the e2e green against a live server: - nav to /ui/repositories (history-mode base /ui/), not a hash route - default the no-input happy path to docker-mcp-catalog (reliably addable) - target the required-input server (default fleur/stripe) independently of the no-input registry, so both tests run from one invocation Verified: both tests pass — search→Add→quarantined and search→Add→prompt→quarantined — against a fresh throwaway core. Co-Authored-By: Paperclip --- e2e/playwright/registry-add.spec.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/e2e/playwright/registry-add.spec.ts b/e2e/playwright/registry-add.spec.ts index b44f5288..027eeb65 100644 --- a/e2e/playwright/registry-add.spec.ts +++ b/e2e/playwright/registry-add.spec.ts @@ -22,7 +22,9 @@ import { test, expect, Page } from '@playwright/test'; const MCPPROXY_URL = process.env.MCPPROXY_URL; const API_KEY = process.env.MCPPROXY_API_KEY || 'uitest'; -const REGISTRY_ID = process.env.REGISTRY_ID || ''; +// docker-mcp-catalog reliably exposes addable stdio servers (docker run …), so +// it is the default target for the no-input happy path. Override per env. +const REGISTRY_ID = process.env.REGISTRY_ID || 'docker-mcp-catalog'; const SEARCH_QUERY = process.env.SEARCH_QUERY || ''; if (!MCPPROXY_URL) { @@ -38,21 +40,22 @@ const api = async (request: any, method: string, path: string) => { }; async function openRepositories(page: Page) { - await page.goto(`${MCPPROXY_URL}/ui/?apikey=${encodeURIComponent(API_KEY)}#/repositories`); + // Web UI is history-mode under base /ui/ (not hash routing). + await page.goto(`${MCPPROXY_URL}/ui/repositories?apikey=${encodeURIComponent(API_KEY)}`); await page.waitForLoadState('domcontentloaded'); // never networkidle — SSE keeps the channel open - await expect(page.locator('[data-test="registry-select"]')).toBeVisible(); + await expect(page.locator('[data-test="registry-select"]')).toBeVisible({ timeout: 15000 }); } -async function selectRegistryAndSearch(page: Page) { +async function selectRegistryAndSearch(page: Page, registryId: string, query: string) { const select = page.locator('[data-test="registry-select"]'); - if (REGISTRY_ID) { - await select.selectOption(REGISTRY_ID); + if (registryId) { + await select.selectOption(registryId); } else { // Pick the first non-placeholder option. const value = await select.locator('option:not([disabled])').first().getAttribute('value'); await select.selectOption(value!); } - await page.locator('[data-test="registry-search-input"]').fill(SEARCH_QUERY); + await page.locator('[data-test="registry-search-input"]').fill(query); await page.locator('[data-test="registry-search-button"]').click(); // Wait for at least one result card. await expect(page.locator('[data-test^="registry-server-"]').first()).toBeVisible({ timeout: 15000 }); @@ -61,7 +64,7 @@ async function selectRegistryAndSearch(page: Page) { test.describe('Registry one-flow add (Spec 070)', () => { test('search → Add (no required input) → server appears quarantined', async ({ page, request }) => { await openRepositories(page); - await selectRegistryAndSearch(page); + await selectRegistryAndSearch(page, REGISTRY_ID, SEARCH_QUERY); // Add the first server without required inputs (no warning badge). const card = page @@ -89,9 +92,13 @@ test.describe('Registry one-flow add (Spec 070)', () => { test('search → Add server that requires input → prompt blocks until provided → quarantined', async ({ page, request }) => { const requiredServerId = process.env.REQUIRED_SERVER_ID; test.skip(!requiredServerId, 'set REQUIRED_SERVER_ID to a registry server that declares a required input'); + // The required-input server may live in a different registry than the + // no-input default; let it be targeted independently (defaults: fleur/stripe). + const requiredRegistry = process.env.REQUIRED_REGISTRY_ID || 'fleur'; + const requiredQuery = process.env.REQUIRED_SEARCH_QUERY || requiredServerId!; await openRepositories(page); - await selectRegistryAndSearch(page); + await selectRegistryAndSearch(page, requiredRegistry, requiredQuery); const card = page.locator(`[data-test="registry-server-${requiredServerId}"]`); await expect(card).toBeVisible(); From f48e3e29e50b2894a4d28df26ecb6e13e4935eb8 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 31 May 2026 20:16:03 +0300 Subject: [PATCH 08/23] feat(070): MCP upstream_servers add_from_registry operation (US3) Adds operation=add_from_registry to the upstream_servers MCP tool so agents can add an upstream from a registry reference (registry + id) without hand-constructing command/args/url. Dispatches to the keystone AddServerFromRegistryRef op: the server re-derives the runnable config from the registry entry and persists it quarantined (CN-001 / CN-002 / security decision D1). New params: registry, id (env_json/name/enabled reuse the existing fields). Success returns the slim AddedServerSummary; failure returns a structured error (isError=true) carrying the same stable Code as REST/CLI plus missing_inputs for missing_required_input (FR-003), so the same failure reads identically across every surface. T012/T013 of spec 070 Phase 4. Tests: internal/server/mcp_add_from_registry_test.go (happy path + missing_required_input structured error). --- internal/server/mcp.go | 12 +- internal/server/mcp_add_from_registry_test.go | 118 ++++++++++++++++++ 2 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 internal/server/mcp_add_from_registry_test.go diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 8c10801a..c0104360 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -634,11 +634,17 @@ func (p *MCPProxyServer) buildManagementTools() []mcpserver.ServerTool { mcp.WithOpenWorldHintAnnotation(false), mcp.WithString("operation", mcp.Required(), - mcp.Description("Operation: list, add, remove, update, patch, tail_log. 'update' and 'patch' use smart merge - only specified fields change, others preserved. For quarantine operations, use the 'quarantine_security' tool."), - mcp.Enum("list", "add", "remove", "update", "patch", "tail_log"), + mcp.Description("Operation: list, add, remove, update, patch, tail_log, add_from_registry. 'update' and 'patch' use smart merge - only specified fields change, others preserved. 'add_from_registry' adds an upstream from a registry reference (registry+id) so you need not hand-construct command/args/url - the server re-derives the runnable config and quarantines it. For quarantine operations, use the 'quarantine_security' tool."), + mcp.Enum("list", "add", "remove", "update", "patch", "tail_log", "add_from_registry"), ), mcp.WithString("name", - mcp.Description("Server name (required for add/remove/update/patch/tail_log operations)"), + mcp.Description("Server name (required for add/remove/update/patch/tail_log operations; optional name override for add_from_registry)"), + ), + mcp.WithString("registry", + mcp.Description("Registry id to add from (e.g. 'pulse') - required for add_from_registry. Use the 'list_registries'/'search_servers' tools to discover registries and server ids."), + ), + mcp.WithString("id", + mcp.Description("Server id within the registry - required for add_from_registry."), ), mcp.WithNumber("lines", mcp.Description("Number of lines to tail from server log (default: 50, max: 500) - used with tail_log operation"), diff --git a/internal/server/mcp_add_from_registry_test.go b/internal/server/mcp_add_from_registry_test.go new file mode 100644 index 00000000..b4ba8837 --- /dev/null +++ b/internal/server/mcp_add_from_registry_test.go @@ -0,0 +1,118 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + mcp "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/registries" +) + +// startTestRegistry registers an in-memory registry (id="testreg") whose server +// list is served by a local httptest server, so add_from_registry can resolve a +// registry reference without touching the network. Returns nothing — the global +// registry catalog is mutated additively via the exported config loader. +func startTestRegistry(t *testing.T, servers []map[string]interface{}) { + t.Helper() + + payload := map[string]interface{}{"servers": servers} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(payload) + })) + t.Cleanup(srv.Close) + + registries.LoadRegistriesFromConfig([]config.RegistryEntry{ + {ID: "testreg", Name: "testreg", URL: srv.URL}, + }, zap.NewNop()) +} + +// callAddFromRegistry drives the upstream_servers handler with the +// add_from_registry operation and returns the raw tool result. +func callAddFromRegistry(t *testing.T, srv *MCPProxyServer, args map[string]interface{}) *mcp.CallToolResult { + t.Helper() + + req := mcp.CallToolRequest{} + req.Params.Name = "upstream_servers" + req.Params.Arguments = args + + result, err := srv.handleUpstreamServers(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, result) + return result +} + +// toolResultJSON extracts and unmarshals the JSON text payload from a tool result. +func toolResultJSON(t *testing.T, result *mcp.CallToolResult) map[string]interface{} { + t.Helper() + + require.NotEmpty(t, result.Content) + tc, ok := result.Content[0].(mcp.TextContent) + require.True(t, ok, "expected text content") + + var payload map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(tc.Text), &payload)) + return payload +} + +// Happy path: operation=add_from_registry {registry,id} resolves the entry, +// re-derives the runnable config server-side, and persists it quarantined — +// equivalent to manual construction (spec 070 checkpoint / CN-004). +func TestHandleUpstreamServers_AddFromRegistry_HappyPath(t *testing.T) { + startTestRegistry(t, []map[string]interface{}{ + {"id": "everything", "name": "everything", "install_cmd": "npx -y @modelcontextprotocol/server-everything"}, + }) + + srv, _ := newBlockedToolsTestServer(t) + + result := callAddFromRegistry(t, srv, map[string]interface{}{ + "operation": "add_from_registry", + "registry": "testreg", + "id": "everything", + }) + + require.False(t, result.IsError, "happy path must not be an error result") + payload := toolResultJSON(t, result) + assert.Equal(t, true, payload["success"]) + + server, ok := payload["server"].(map[string]interface{}) + require.True(t, ok, "success payload must carry a server object") + assert.Equal(t, "everything", server["name"]) + assert.Equal(t, "stdio", server["protocol"]) + assert.Equal(t, "npx", server["command"]) + assert.Equal(t, true, server["quarantined"], "new registry server must be quarantined (CN-002)") +} + +// Missing required input: the entry declares ${GITHUB_TOKEN} but the request +// supplies no env. The handler must return a structured error (isError=true) +// carrying the stable cross-surface code and the offending input names (FR-003). +func TestHandleUpstreamServers_AddFromRegistry_MissingRequiredInput(t *testing.T) { + startTestRegistry(t, []map[string]interface{}{ + {"id": "gh", "name": "gh", "install_cmd": "npx github-mcp --token ${GITHUB_TOKEN}"}, + }) + + srv, _ := newBlockedToolsTestServer(t) + + result := callAddFromRegistry(t, srv, map[string]interface{}{ + "operation": "add_from_registry", + "registry": "testreg", + "id": "gh", + }) + + require.True(t, result.IsError, "missing required input must be an error result") + payload := toolResultJSON(t, result) + assert.Equal(t, false, payload["success"]) + assert.Equal(t, "missing_required_input", payload["code"]) + + missing, ok := payload["missing_inputs"].([]interface{}) + require.True(t, ok, "missing_required_input must list the offending inputs") + assert.Equal(t, []interface{}{"GITHUB_TOKEN"}, missing) +} From 4d96f93dcfdbe09bfbc7f759db89c8dfe34760ae Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 31 May 2026 20:23:07 +0300 Subject: [PATCH 09/23] fix(070): complete add_from_registry MCP op + working tests The prior commit added only the upstream_servers enum/param surface; the dispatch case and handler were missing and the test referenced undefined helpers, so the package did not compile. This wires it up end-to-end: - handleAddServerFromRegistry dispatches to the keystone mainServer.AddServerFromRegistryRef; success -> AddedServerSummary, failure -> structured error (code + missing_inputs, isError=true) matching the REST/CLI surfaces (CN-001). - add_from_registry added to the dispatch switch and the agent-token block list (write-op parity). - Tests use a real runtime-backed mainServer plus an httptest registry (SetRegistriesFromConfig): happy path asserts a quarantined stdio config, missing-input path asserts the structured missing_required_input error with the offending input names. Verified: go test ./internal/server (incl. the two new tests), go build ./..., golangci-lint run ./internal/server/... all pass. --- internal/server/mcp.go | 77 ++++++++++++++++++- internal/server/mcp_add_from_registry_test.go | 43 ++++++++--- 2 files changed, 110 insertions(+), 10 deletions(-) diff --git a/internal/server/mcp.go b/internal/server/mcp.go index c0104360..7d236622 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -2315,6 +2315,79 @@ func (p *MCPProxyServer) handleQuarantinedToolCall(ctx context.Context, serverNa return mcp.NewToolResultText(string(jsonResult)) } +// handleAddServerFromRegistry implements the upstream_servers add_from_registry +// operation (spec 070, Phase 4 / US3). An agent supplies a registry reference +// (registry + id) plus optional name/env_json/enabled overrides; the server +// re-derives the runnable config from the registry entry (CN-001 / security +// decision D1) and persists it quarantined, so agents need not hand-construct +// command/args/url. On failure it returns a structured error (isError=true) +// carrying the same stable Code as the REST/CLI surfaces, plus the offending +// input names for missing_required_input (FR-003). +func (p *MCPProxyServer) handleAddServerFromRegistry(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + registryID := request.GetString("registry", "") + serverID := request.GetString("id", "") + if registryID == "" || serverID == "" { + return mcp.NewToolResultError("add_from_registry requires both 'registry' and 'id'"), nil + } + + name := request.GetString("name", "") + + var env map[string]string + if envJSON := request.GetString("env_json", ""); envJSON != "" { + if err := json.Unmarshal([]byte(envJSON), &env); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid env_json format: %v", err)), nil + } + } + + // enabled defaults to true; an explicit false disables on add. + enabledVal := request.GetBool("enabled", true) + + if p.mainServer == nil { + return mcp.NewToolResultError("Server management is not available"), nil + } + + cfg, rerr, err := p.mainServer.AddServerFromRegistryRef(ctx, registryID, serverID, name, env, &enabledVal) + if err != nil { + // Structured cross-surface error: same Code as REST/CLI (CN-001), only + // the envelope differs (JSON text + isError instead of an HTTP status). + errPayload := map[string]interface{}{ + "success": false, + "message": err.Error(), + } + if rerr != nil { + errPayload["code"] = rerr.Code + if len(rerr.MissingInputs) > 0 { + errPayload["missing_inputs"] = rerr.MissingInputs + } + } + jsonData, mErr := json.Marshal(errPayload) + if mErr != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultError(string(jsonData)), nil + } + + // Slim, stable projection mirroring the REST AddedServerSummary so every + // surface reports the persisted server identically. + summary := contracts.AddedServerSummary{ + Name: cfg.Name, + Protocol: cfg.Protocol, + Command: cfg.Command, + Args: cfg.Args, + URL: cfg.URL, + Enabled: cfg.Enabled, + Quarantined: cfg.Quarantined, + } + jsonData, err := json.Marshal(map[string]interface{}{ + "success": true, + "server": summary, + }) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to serialize result: %v", err)), nil + } + return mcp.NewToolResultText(string(jsonData)), nil +} + // handleUpstreamServers implements upstream server management func (p *MCPProxyServer) handleUpstreamServers(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { p.recordMCPSurface() @@ -2372,7 +2445,7 @@ func (p *MCPProxyServer) handleUpstreamServers(ctx context.Context, request mcp. // Spec 028: Agent tokens can only list servers (filtered to allowed) — block all write operations if authCtx := auth.AuthContextFromContext(ctx); authCtx != nil && !authCtx.IsAdmin() { switch operation { - case operationAdd, operationRemove, "update", "patch", "enable", "disable", "restart": + case operationAdd, operationRemove, "update", "patch", "enable", "disable", "restart", "add_from_registry": errMsg := fmt.Sprintf("Agent tokens cannot perform '%s' operations on upstream servers", operation) p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", errMsg, time.Since(startTime).Milliseconds(), args, nil, nil, "") return mcp.NewToolResultError(errMsg), nil @@ -2402,6 +2475,8 @@ func (p *MCPProxyServer) handleUpstreamServers(ctx context.Context, request mcp. result, opErr = p.handleEnableUpstream(ctx, request, false) case "restart": result, opErr = p.handleRestartUpstream(ctx, request) + case "add_from_registry": + result, opErr = p.handleAddServerFromRegistry(ctx, request) default: p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", fmt.Sprintf("Unknown operation: %s", operation), time.Since(startTime).Milliseconds(), args, nil, nil, "") return mcp.NewToolResultError(fmt.Sprintf("Unknown operation: %s", operation)), nil diff --git a/internal/server/mcp_add_from_registry_test.go b/internal/server/mcp_add_from_registry_test.go index b4ba8837..52d46d96 100644 --- a/internal/server/mcp_add_from_registry_test.go +++ b/internal/server/mcp_add_from_registry_test.go @@ -14,12 +14,35 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" "github.com/smart-mcp-proxy/mcpproxy-go/internal/registries" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" ) +// newAddFromRegistryTestServer builds an MCPProxyServer whose mainServer is a +// real *Server backed by a live runtime+storage, so add_from_registry can run +// through the keystone op (resolve → derive → persist) end-to-end. The base +// createTestMCPProxyServer wires mainServer=nil, which is enough for read paths +// but not for this write op. +func newAddFromRegistryTestServer(t *testing.T) *MCPProxyServer { + t.Helper() + + proxy := createTestMCPProxyServer(t) + + logger := zap.NewNop() + cfg := config.DefaultConfig() + cfg.DataDir = t.TempDir() + cfg.Listen = "127.0.0.1:0" + + rt, err := runtime.NewRuntime(cfg, logger, "test") + require.NoError(t, err) + + proxy.mainServer = NewServer(rt, logger) + return proxy +} + // startTestRegistry registers an in-memory registry (id="testreg") whose server // list is served by a local httptest server, so add_from_registry can resolve a -// registry reference without touching the network. Returns nothing — the global -// registry catalog is mutated additively via the exported config loader. +// registry reference without touching the network. SetRegistriesFromConfig +// replaces the global catalog; tests run sequentially so the last writer wins. func startTestRegistry(t *testing.T, servers []map[string]interface{}) { t.Helper() @@ -30,9 +53,11 @@ func startTestRegistry(t *testing.T, servers []map[string]interface{}) { })) t.Cleanup(srv.Close) - registries.LoadRegistriesFromConfig([]config.RegistryEntry{ - {ID: "testreg", Name: "testreg", URL: srv.URL}, - }, zap.NewNop()) + registries.SetRegistriesFromConfig(&config.Config{ + Registries: []config.RegistryEntry{ + {ID: "testreg", Name: "testreg", ServersURL: srv.URL, Protocol: "modelcontextprotocol/registry"}, + }, + }) } // callAddFromRegistry drives the upstream_servers handler with the @@ -68,10 +93,10 @@ func toolResultJSON(t *testing.T, result *mcp.CallToolResult) map[string]interfa // equivalent to manual construction (spec 070 checkpoint / CN-004). func TestHandleUpstreamServers_AddFromRegistry_HappyPath(t *testing.T) { startTestRegistry(t, []map[string]interface{}{ - {"id": "everything", "name": "everything", "install_cmd": "npx -y @modelcontextprotocol/server-everything"}, + {"id": "everything", "name": "everything", "installCmd": "npx -y @modelcontextprotocol/server-everything"}, }) - srv, _ := newBlockedToolsTestServer(t) + srv := newAddFromRegistryTestServer(t) result := callAddFromRegistry(t, srv, map[string]interface{}{ "operation": "add_from_registry", @@ -96,10 +121,10 @@ func TestHandleUpstreamServers_AddFromRegistry_HappyPath(t *testing.T) { // carrying the stable cross-surface code and the offending input names (FR-003). func TestHandleUpstreamServers_AddFromRegistry_MissingRequiredInput(t *testing.T) { startTestRegistry(t, []map[string]interface{}{ - {"id": "gh", "name": "gh", "install_cmd": "npx github-mcp --token ${GITHUB_TOKEN}"}, + {"id": "gh", "name": "gh", "installCmd": "npx github-mcp --token ${GITHUB_TOKEN}"}, }) - srv, _ := newBlockedToolsTestServer(t) + srv := newAddFromRegistryTestServer(t) result := callAddFromRegistry(t, srv, map[string]interface{}{ "operation": "add_from_registry", From 5771d4348d7cfa6d9de2c5d4bf0e280adbb16ba5 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 31 May 2026 20:26:41 +0300 Subject: [PATCH 10/23] fix(070): correct add_from_registry test harness (NewServer(cfg,logger)) --- internal/server/mcp_add_from_registry_test.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/server/mcp_add_from_registry_test.go b/internal/server/mcp_add_from_registry_test.go index 52d46d96..9c119dec 100644 --- a/internal/server/mcp_add_from_registry_test.go +++ b/internal/server/mcp_add_from_registry_test.go @@ -14,7 +14,6 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" "github.com/smart-mcp-proxy/mcpproxy-go/internal/registries" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" ) // newAddFromRegistryTestServer builds an MCPProxyServer whose mainServer is a @@ -32,10 +31,10 @@ func newAddFromRegistryTestServer(t *testing.T) *MCPProxyServer { cfg.DataDir = t.TempDir() cfg.Listen = "127.0.0.1:0" - rt, err := runtime.NewRuntime(cfg, logger, "test") + mainSrv, err := NewServer(cfg, logger) require.NoError(t, err) - proxy.mainServer = NewServer(rt, logger) + proxy.mainServer = mainSrv return proxy } From a5a48cf61a7a12c27fc9f8a68537ef94296f7182 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 31 May 2026 20:36:17 +0300 Subject: [PATCH 11/23] feat(070): merge built-in registry defaults with config by ID (US4 FR-006) SetRegistriesFromConfig previously replaced the registry list wholesale, so a user who added one custom registry in their config dropped all built-in defaults. Change it to merge defaults union config keyed by ID: defaults first, custom appended, colliding IDs override in place. Extract the five built-in registries into config.DefaultRegistries() as the single source of truth shared by DefaultConfig() and the merge. Add RequiresKey to both RegistryEntry types (plumbing for FR-008). Related #765 --- internal/config/config.go | 110 ++++++++++++---------- internal/registries/registry_data.go | 70 ++++++++------ internal/registries/registry_data_test.go | 86 +++++++++++++++++ internal/registries/types.go | 5 + 4 files changed, 194 insertions(+), 77 deletions(-) create mode 100644 internal/registries/registry_data_test.go diff --git a/internal/config/config.go b/internal/config/config.go index a6761c7b..60e5cf2e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -639,6 +639,10 @@ type RegistryEntry struct { Tags []string `json:"tags,omitempty"` Protocol string `json:"protocol,omitempty"` Count interface{} `json:"count,omitempty" swaggertype:"primitive,string"` // number or string + // RequiresKey marks a registry that needs an API key to be queried. When + // true and no key is configured, the registry is skipped/marked unavailable + // rather than failing the whole search (FR-008). + RequiresKey bool `json:"requires_key,omitempty"` } // CursorMCPConfig represents the structure for Cursor IDE MCP configuration @@ -808,6 +812,60 @@ func DefaultDockerIsolationConfig() *DockerIsolationConfig { } } +// DefaultRegistries returns the built-in MCP server discovery registries. It is +// the single source of truth for the shipped defaults: DefaultConfig() seeds +// them into a fresh config, and the registries package merges them with any +// user-defined entries so a custom registry never drops the defaults (FR-006). +func DefaultRegistries() []RegistryEntry { + return []RegistryEntry{ + { + ID: "pulse", + Name: "Pulse MCP", + Description: "Browse and discover MCP use-cases, servers, clients, and news", + URL: "https://www.pulsemcp.com/", + ServersURL: "https://api.pulsemcp.com/v0beta/servers", + Tags: []string{"verified"}, + Protocol: "custom/pulse", + }, + { + ID: "docker-mcp-catalog", + Name: "Docker MCP Catalog", + Description: "A collection of secure, high-quality MCP servers as docker images", + URL: "https://hub.docker.com/catalogs/mcp", + ServersURL: "https://hub.docker.com/v2/repositories/mcp/", + Tags: []string{"verified"}, + Protocol: "custom/docker", + }, + { + ID: "fleur", + Name: "Fleur", + Description: "Fleur is the app store for Claude", + URL: "https://www.fleurmcp.com/", + ServersURL: "https://raw.githubusercontent.com/fleuristes/app-registry/refs/heads/main/apps.json", + Tags: []string{"verified"}, + Protocol: "custom/fleur", + }, + { + ID: "azure-mcp-demo", + Name: "Azure MCP Registry Demo", + Description: "A reference implementation of MCP registry using Azure API Center", + URL: "https://demo.registry.azure-mcp.net/", + ServersURL: "https://demo.registry.azure-mcp.net/v0/servers", + Tags: []string{"verified", "demo", "azure", "reference"}, + Protocol: "mcp/v0", + }, + { + ID: "remote-mcp-servers", + Name: "Remote MCP Servers", + Description: "Community-maintained list of remote Model Context Protocol servers", + URL: "https://remote-mcp-servers.com/", + ServersURL: "https://remote-mcp-servers.com/api/servers", + Tags: []string{"verified", "community", "remote"}, + Protocol: "custom/remote", + }, + } +} + // DefaultConfig returns a default configuration func DefaultConfig() *Config { return &Config{ @@ -862,54 +920,10 @@ func DefaultConfig() *Config { // Default output sanitisation settings (Spec 054 Track B) OutputSanitisation: DefaultOutputSanitisationConfig(), - // Default registries for MCP server discovery - Registries: []RegistryEntry{ - { - ID: "pulse", - Name: "Pulse MCP", - Description: "Browse and discover MCP use-cases, servers, clients, and news", - URL: "https://www.pulsemcp.com/", - ServersURL: "https://api.pulsemcp.com/v0beta/servers", - Tags: []string{"verified"}, - Protocol: "custom/pulse", - }, - { - ID: "docker-mcp-catalog", - Name: "Docker MCP Catalog", - Description: "A collection of secure, high-quality MCP servers as docker images", - URL: "https://hub.docker.com/catalogs/mcp", - ServersURL: "https://hub.docker.com/v2/repositories/mcp/", - Tags: []string{"verified"}, - Protocol: "custom/docker", - }, - { - ID: "fleur", - Name: "Fleur", - Description: "Fleur is the app store for Claude", - URL: "https://www.fleurmcp.com/", - ServersURL: "https://raw.githubusercontent.com/fleuristes/app-registry/refs/heads/main/apps.json", - Tags: []string{"verified"}, - Protocol: "custom/fleur", - }, - { - ID: "azure-mcp-demo", - Name: "Azure MCP Registry Demo", - Description: "A reference implementation of MCP registry using Azure API Center", - URL: "https://demo.registry.azure-mcp.net/", - ServersURL: "https://demo.registry.azure-mcp.net/v0/servers", - Tags: []string{"verified", "demo", "azure", "reference"}, - Protocol: "mcp/v0", - }, - { - ID: "remote-mcp-servers", - Name: "Remote MCP Servers", - Description: "Community-maintained list of remote Model Context Protocol servers", - URL: "https://remote-mcp-servers.com/", - ServersURL: "https://remote-mcp-servers.com/api/servers", - Tags: []string{"verified", "community", "remote"}, - Protocol: "custom/remote", - }, - }, + // Default registries for MCP server discovery. Sourced from + // DefaultRegistries() so the built-in list has a single definition that + // the registries-package merge (FR-006) can reuse. + Registries: DefaultRegistries(), // Default feature flags Features: func() *FeatureFlags { diff --git a/internal/registries/registry_data.go b/internal/registries/registry_data.go index 7027ba6f..27123c2e 100644 --- a/internal/registries/registry_data.go +++ b/internal/registries/registry_data.go @@ -6,39 +6,51 @@ import ( var registryList []RegistryEntry -// SetRegistriesFromConfig sets the registries list from configuration +// SetRegistriesFromConfig builds the effective registry list by MERGING the +// built-in defaults with the user's configured registries, keyed by ID +// (FR-006). Built-in defaults come first (in their canonical order); a config +// entry with a new ID is appended, and a config entry whose ID collides with a +// default overrides it in place. This means adding one custom registry no +// longer drops the shipped defaults, and no rebuild is required. func SetRegistriesFromConfig(cfg *config.Config) { - if cfg != nil && cfg.Registries != nil { - // Convert config.RegistryEntry to registries.RegistryEntry - registryList = make([]RegistryEntry, len(cfg.Registries)) - for i := range cfg.Registries { - r := &cfg.Registries[i] - registryList[i] = RegistryEntry{ - ID: r.ID, - Name: r.Name, - Description: r.Description, - URL: r.URL, - ServersURL: r.ServersURL, - Tags: r.Tags, - Protocol: r.Protocol, - Count: r.Count, - } + index := make(map[string]int) // ID -> position in merged + merged := make([]RegistryEntry, 0, len(config.DefaultRegistries())) + + upsert := func(r RegistryEntry) { + if pos, ok := index[r.ID]; ok { + merged[pos] = r + return } - } else { - // Use default registries - registryList = []RegistryEntry{ - { - ID: "smithery", - Name: "Smithery MCP Registry", - Description: "The official community registry for Model Context Protocol (MCP) servers.", - URL: "https://smithery.ai/protocols", - ServersURL: "https://smithery.ai/api/smithery-protocol-registry", - Tags: []string{"official", "community"}, - Protocol: "modelcontextprotocol/registry", - Count: -1, // Will be populated at runtime - }, + index[r.ID] = len(merged) + merged = append(merged, r) + } + + defaults := config.DefaultRegistries() + for i := range defaults { + upsert(fromConfigEntry(&defaults[i])) + } + if cfg != nil { + for i := range cfg.Registries { + upsert(fromConfigEntry(&cfg.Registries[i])) } } + + registryList = merged +} + +// fromConfigEntry converts a config.RegistryEntry to a registries.RegistryEntry. +func fromConfigEntry(r *config.RegistryEntry) RegistryEntry { + return RegistryEntry{ + ID: r.ID, + Name: r.Name, + Description: r.Description, + URL: r.URL, + ServersURL: r.ServersURL, + Tags: r.Tags, + Protocol: r.Protocol, + Count: r.Count, + RequiresKey: r.RequiresKey, + } } // ListRegistries returns a copy of all available registries diff --git a/internal/registries/registry_data_test.go b/internal/registries/registry_data_test.go new file mode 100644 index 00000000..7bc516c5 --- /dev/null +++ b/internal/registries/registry_data_test.go @@ -0,0 +1,86 @@ +package registries + +import ( + "testing" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// defaultRegistryIDs are the five built-in registries shipped in +// config.DefaultConfig(). A user-supplied config must MERGE with these, not +// replace them (FR-006). +var defaultRegistryIDs = []string{ + "pulse", + "docker-mcp-catalog", + "fleur", + "azure-mcp-demo", + "remote-mcp-servers", +} + +func registryIDSet(t *testing.T) map[string]RegistryEntry { + t.Helper() + out := map[string]RegistryEntry{} + for _, r := range ListRegistries() { + out[r.ID] = r + } + return out +} + +// FR-006: a custom registry from config must not drop the 5 built-in defaults. +func TestSetRegistriesFromConfig_MergesCustomWithDefaults(t *testing.T) { + cfg := &config.Config{ + Registries: []config.RegistryEntry{ + {ID: "mycorp", Name: "My Corp Registry", ServersURL: "https://reg.mycorp.example/servers"}, + }, + } + + SetRegistriesFromConfig(cfg) + + got := registryIDSet(t) + for _, id := range defaultRegistryIDs { + if _, ok := got[id]; !ok { + t.Errorf("default registry %q was dropped after merging a custom entry", id) + } + } + if _, ok := got["mycorp"]; !ok { + t.Errorf("custom registry %q missing after merge", "mycorp") + } + if len(got) != len(defaultRegistryIDs)+1 { + t.Errorf("expected %d registries after merge, got %d", len(defaultRegistryIDs)+1, len(got)) + } +} + +// FR-006: a config entry whose ID collides with a default overrides it in place +// (no duplicate, default count preserved). +func TestSetRegistriesFromConfig_CustomOverridesDefaultByID(t *testing.T) { + cfg := &config.Config{ + Registries: []config.RegistryEntry{ + {ID: "pulse", Name: "Pulse OVERRIDDEN", ServersURL: "https://override.example/servers"}, + }, + } + + SetRegistriesFromConfig(cfg) + + got := registryIDSet(t) + if len(got) != len(defaultRegistryIDs) { + t.Errorf("override should not change registry count: want %d got %d", len(defaultRegistryIDs), len(got)) + } + if got["pulse"].Name != "Pulse OVERRIDDEN" { + t.Errorf("colliding-ID config entry did not override default: got name %q", got["pulse"].Name) + } +} + +// Nil/empty config yields exactly the built-in defaults. +func TestSetRegistriesFromConfig_NilConfigUsesDefaults(t *testing.T) { + SetRegistriesFromConfig(nil) + + got := registryIDSet(t) + if len(got) != len(defaultRegistryIDs) { + t.Errorf("nil config should give %d defaults, got %d", len(defaultRegistryIDs), len(got)) + } + for _, id := range defaultRegistryIDs { + if _, ok := got[id]; !ok { + t.Errorf("default registry %q missing for nil config", id) + } + } +} diff --git a/internal/registries/types.go b/internal/registries/types.go index 19efc043..5958d692 100644 --- a/internal/registries/types.go +++ b/internal/registries/types.go @@ -12,6 +12,11 @@ type RegistryEntry struct { Tags []string `json:"tags,omitempty"` Protocol string `json:"protocol,omitempty"` Count interface{} `json:"count,omitempty"` // number or string + // RequiresKey marks a registry that needs an API key to be queried. When + // true and no key is configured, SearchServers skips it with + // ErrRegistryKeyMissing so the calling surface can mark it unavailable + // instead of failing the whole search (FR-008). + RequiresKey bool `json:"requires_key,omitempty"` } // ServerEntry represents an MCP server discovered via a registry From d90324a44a84fe308989f21f1172e05dd19f44e3 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 31 May 2026 20:37:49 +0300 Subject: [PATCH 12/23] feat(070): skip key-requiring registry when key absent (US4 FR-008) A registry marked RequiresKey is skipped via ErrRegistryKeyMissing when no API key is configured (env MCPPROXY_REGISTRY__API_KEY), instead of running a doomed fetch. SearchServers short-circuits before the network call; FindServerByID inherits the behaviour. Calling surfaces map the sentinel to an 'unavailable' marker so the overall search still succeeds. Related #765 --- internal/registries/registry_key.go | 52 +++++++++++++++++++++ internal/registries/registry_key_test.go | 57 ++++++++++++++++++++++++ internal/registries/search.go | 7 +++ 3 files changed, 116 insertions(+) create mode 100644 internal/registries/registry_key.go create mode 100644 internal/registries/registry_key_test.go diff --git a/internal/registries/registry_key.go b/internal/registries/registry_key.go new file mode 100644 index 00000000..a22a86d5 --- /dev/null +++ b/internal/registries/registry_key.go @@ -0,0 +1,52 @@ +package registries + +import ( + "errors" + "fmt" + "os" + "strings" +) + +// ErrRegistryKeyMissing is returned when a registry declares RequiresKey but no +// API key is configured for it. Calling surfaces should treat this as +// "registry unavailable" and continue rather than failing the whole search +// (FR-008 / SC-006). +var ErrRegistryKeyMissing = errors.New("registry requires an API key that is not configured") + +// RegistryKeyEnvVar returns the environment variable a key-requiring registry +// reads its API key from: MCPPROXY_REGISTRY__API_KEY, with the ID +// upper-cased and any non-alphanumeric character replaced by an underscore. +// e.g. "azure-mcp-demo" -> "MCPPROXY_REGISTRY_AZURE_MCP_DEMO_API_KEY". +func RegistryKeyEnvVar(id string) string { + var b strings.Builder + b.WriteString("MCPPROXY_REGISTRY_") + for _, r := range strings.ToUpper(id) { + switch { + case r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + b.WriteString("_API_KEY") + return b.String() +} + +// registryAPIKey resolves the configured API key for a registry, or "" when +// none is set. +func registryAPIKey(reg *RegistryEntry) string { + return os.Getenv(RegistryKeyEnvVar(reg.ID)) +} + +// checkRegistryKey enforces FR-008: when a registry requires a key and none is +// configured, it returns a wrapped ErrRegistryKeyMissing naming the env var to +// set. Returns nil when the registry needs no key or one is present. +func checkRegistryKey(reg *RegistryEntry) error { + if !reg.RequiresKey { + return nil + } + if registryAPIKey(reg) == "" { + return fmt.Errorf("%w: set %s for registry %q", ErrRegistryKeyMissing, RegistryKeyEnvVar(reg.ID), reg.ID) + } + return nil +} diff --git a/internal/registries/registry_key_test.go b/internal/registries/registry_key_test.go new file mode 100644 index 00000000..f5761c29 --- /dev/null +++ b/internal/registries/registry_key_test.go @@ -0,0 +1,57 @@ +package registries + +import ( + "context" + "errors" + "testing" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +func TestRegistryKeyEnvVar(t *testing.T) { + cases := map[string]string{ + "pulse": "MCPPROXY_REGISTRY_PULSE_API_KEY", + "my-corp": "MCPPROXY_REGISTRY_MY_CORP_API_KEY", + "azure-mcp-demo": "MCPPROXY_REGISTRY_AZURE_MCP_DEMO_API_KEY", + } + for id, want := range cases { + if got := RegistryKeyEnvVar(id); got != want { + t.Errorf("RegistryKeyEnvVar(%q) = %q, want %q", id, got, want) + } + } +} + +// FR-008: a registry that requires a key with none configured is skipped via +// ErrRegistryKeyMissing rather than performing a network fetch or erroring +// opaquely. +func TestSearchServers_KeyAbsentSkipped(t *testing.T) { + cfg := &config.Config{ + Registries: []config.RegistryEntry{ + {ID: "needs-key", Name: "Key Required", ServersURL: "https://example.invalid/servers", RequiresKey: true}, + }, + } + SetRegistriesFromConfig(cfg) + t.Setenv("MCPPROXY_REGISTRY_NEEDS_KEY_API_KEY", "") // ensure absent + + _, err := SearchServers(context.Background(), "needs-key", "", "", 10, nil) + if !errors.Is(err, ErrRegistryKeyMissing) { + t.Fatalf("expected ErrRegistryKeyMissing, got %v", err) + } +} + +// When the key IS configured, the registry is not skipped — the key check is +// bypassed and a different (non-sentinel) path runs. +func TestSearchServers_KeyPresentNotSkipped(t *testing.T) { + cfg := &config.Config{ + Registries: []config.RegistryEntry{ + {ID: "needs-key", Name: "Key Required", ServersURL: "", RequiresKey: true}, + }, + } + SetRegistriesFromConfig(cfg) + t.Setenv("MCPPROXY_REGISTRY_NEEDS_KEY_API_KEY", "sk-test-123") + + _, err := SearchServers(context.Background(), "needs-key", "", "", 10, nil) + if errors.Is(err, ErrRegistryKeyMissing) { + t.Fatalf("key is present; should not be skipped as key-missing, got %v", err) + } +} diff --git a/internal/registries/search.go b/internal/registries/search.go index ad757b04..ccc78625 100644 --- a/internal/registries/search.go +++ b/internal/registries/search.go @@ -50,6 +50,13 @@ func SearchServers(ctx context.Context, registryID, tag, query string, limit int return nil, fmt.Errorf("registry '%s' not found", registryID) } + // FR-008: skip a key-requiring registry when no key is configured, rather + // than performing a doomed fetch. Surfaces map ErrRegistryKeyMissing to an + // "unavailable" marker so the overall search still succeeds. + if err := checkRegistryKey(reg); err != nil { + return nil, err + } + if reg.ServersURL == "" { return nil, fmt.Errorf("registry '%s' has no servers endpoint", reg.Name) } From da5f9529da167ba36ef6152c263557386d381f5e Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 31 May 2026 20:39:08 +0300 Subject: [PATCH 13/23] feat(070): add Invalidate/Refresh/InvalidatePrefix/Peek to cache manager (US4 FR-007) Primitives for registry cache freshness: Invalidate drops a key, Refresh aliases it (lazy re-fetch), InvalidatePrefix drops all keys under a stable prefix (one refresh clears every tag/query variant of a registry), and Peek returns a record without evicting it even when expired so callers can serve stale data while flagging its age. All stats-accounted. Related #765 --- internal/cache/manager.go | 91 ++++++++++++++++ internal/cache/manager_freshness_test.go | 130 +++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 internal/cache/manager_freshness_test.go diff --git a/internal/cache/manager.go b/internal/cache/manager.go index 215a732d..b2c907b7 100644 --- a/internal/cache/manager.go +++ b/internal/cache/manager.go @@ -220,6 +220,97 @@ func (m *Manager) GetStats() *Stats { return m.stats } +// Invalidate removes a single cache entry, forcing the next access to miss. +// It is a no-op (nil error) if the key is absent. Used by the registry refresh +// path (FR-007) to drop cached server lists on demand. +func (m *Manager) Invalidate(key string) error { + return m.db.Update(func(tx *bbolt.Tx) error { + bucket := tx.Bucket([]byte(CacheBucket)) + data := bucket.Get([]byte(key)) + if data == nil { + return nil + } + var record Record + if err := record.UnmarshalBinary(data); err == nil { + m.stats.TotalEntries-- + m.stats.TotalSizeBytes -= record.TotalSize + } + if err := bucket.Delete([]byte(key)); err != nil { + return fmt.Errorf("invalidate cache key: %w", err) + } + return m.saveStats(tx) + }) +} + +// Refresh forces the next access to re-fetch by invalidating the cached entry. +// The cache manager has no knowledge of the upstream source, so "refresh" is a +// lazy operation: it drops the stale value and the caller re-populates it on +// the next Store. Provided alongside Invalidate to match the data model (FR-007). +func (m *Manager) Refresh(key string) error { + return m.Invalidate(key) +} + +// InvalidatePrefix removes every cache entry whose key starts with prefix and +// returns how many were deleted. Registry caches are keyed by a stable prefix +// (e.g. "registry-servers::") so a single refresh can drop all variants of +// a registry's cached results regardless of tag/query/limit (FR-007). +func (m *Manager) InvalidatePrefix(prefix string) (int, error) { + deleted := 0 + err := m.db.Update(func(tx *bbolt.Tx) error { + bucket := tx.Bucket([]byte(CacheBucket)) + cursor := bucket.Cursor() + + var keysToDelete [][]byte + var sizeReduced int + for key, value := cursor.First(); key != nil; key, value = cursor.Next() { + if !strings.HasPrefix(string(key), prefix) { + continue + } + keyCopy := make([]byte, len(key)) + copy(keyCopy, key) + keysToDelete = append(keysToDelete, keyCopy) + var record Record + if err := record.UnmarshalBinary(value); err == nil { + sizeReduced += record.TotalSize + } + } + + for _, key := range keysToDelete { + if err := bucket.Delete(key); err != nil { + return fmt.Errorf("invalidate prefix key: %w", err) + } + } + deleted = len(keysToDelete) + m.stats.TotalEntries -= deleted + m.stats.TotalSizeBytes -= sizeReduced + return m.saveStats(tx) + }) + return deleted, err +} + +// Peek returns a cached record WITHOUT evicting it or mutating access stats, +// even when the entry has expired. Unlike Get (which deletes expired entries +// and is the read path for fresh data), Peek lets the registry layer serve a +// stale value while still flagging its age — callers derive freshness from +// time.Since(record.CreatedAt) and record.IsExpired() (FR-007). The boolean is +// false only when the key is absent. +func (m *Manager) Peek(key string) (*Record, bool) { + var record *Record + _ = m.db.View(func(tx *bbolt.Tx) error { + data := tx.Bucket([]byte(CacheBucket)).Get([]byte(key)) + if data == nil { + return nil + } + rec := &Record{} + if err := rec.UnmarshalBinary(data); err != nil { + return nil + } + record = rec + return nil + }) + return record, record != nil +} + // startCleanup runs periodic cleanup of expired cache entries func (m *Manager) startCleanup() { ticker := time.NewTicker(CleanupInterval) diff --git a/internal/cache/manager_freshness_test.go b/internal/cache/manager_freshness_test.go new file mode 100644 index 00000000..ea461d28 --- /dev/null +++ b/internal/cache/manager_freshness_test.go @@ -0,0 +1,130 @@ +package cache + +import ( + "testing" + "time" + + "go.etcd.io/bbolt" + "go.uber.org/zap" +) + +func newTestManager(t *testing.T) *Manager { + t.Helper() + db := setupTestDB(t) + t.Cleanup(func() { db.Close() }) + m, err := NewManager(db, zap.NewNop()) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + t.Cleanup(m.Close) + return m +} + +// putRaw writes a record directly so tests can craft timestamps (e.g. a stale +// entry) that the public Store API would not produce. +func putRaw(t *testing.T, m *Manager, rec *Record) { + t.Helper() + err := m.db.Update(func(tx *bbolt.Tx) error { + data, err := rec.MarshalBinary() + if err != nil { + return err + } + return tx.Bucket([]byte(CacheBucket)).Put([]byte(rec.Key), data) + }) + if err != nil { + t.Fatalf("putRaw: %v", err) + } +} + +func TestInvalidate_RemovesKey(t *testing.T) { + m := newTestManager(t) + if err := m.Store("k1", "tool", nil, `[]`, "", 0); err != nil { + t.Fatalf("Store: %v", err) + } + if _, err := m.Get("k1"); err != nil { + t.Fatalf("precondition Get: %v", err) + } + if err := m.Invalidate("k1"); err != nil { + t.Fatalf("Invalidate: %v", err) + } + if _, err := m.Get("k1"); err == nil { + t.Fatal("expected key to be gone after Invalidate") + } +} + +func TestRefresh_ForcesReFetch(t *testing.T) { + m := newTestManager(t) + if err := m.Store("k1", "tool", nil, `[]`, "", 0); err != nil { + t.Fatalf("Store: %v", err) + } + if err := m.Refresh("k1"); err != nil { + t.Fatalf("Refresh: %v", err) + } + if _, err := m.Get("k1"); err == nil { + t.Fatal("expected key to be gone after Refresh (next access re-fetches)") + } +} + +func TestInvalidatePrefix_OnlyMatching(t *testing.T) { + m := newTestManager(t) + for _, k := range []string{"reg:a", "reg:b", "other:c"} { + if err := m.Store(k, "tool", nil, `[]`, "", 0); err != nil { + t.Fatalf("Store %s: %v", k, err) + } + } + n, err := m.InvalidatePrefix("reg:") + if err != nil { + t.Fatalf("InvalidatePrefix: %v", err) + } + if n != 2 { + t.Errorf("expected 2 keys invalidated, got %d", n) + } + if _, err := m.Get("other:c"); err != nil { + t.Errorf("non-matching key should survive: %v", err) + } +} + +func TestPeek_FreshEntry(t *testing.T) { + m := newTestManager(t) + if err := m.Store("k1", "tool", nil, `[]`, "", 0); err != nil { + t.Fatalf("Store: %v", err) + } + rec, ok := m.Peek("k1") + if !ok { + t.Fatal("Peek should find a fresh entry") + } + if time.Since(rec.CreatedAt) > time.Minute { + t.Errorf("fresh entry age unexpectedly large: %v", time.Since(rec.CreatedAt)) + } + if rec.IsExpired() { + t.Error("fresh entry should not be stale") + } +} + +func TestPeek_StaleEntryNotEvicted(t *testing.T) { + m := newTestManager(t) + putRaw(t, m, &Record{ + Key: "stale", + ToolName: "tool", + CreatedAt: time.Now().Add(-3 * time.Hour), + ExpiresAt: time.Now().Add(-1 * time.Hour), + }) + rec, ok := m.Peek("stale") + if !ok { + t.Fatal("Peek should return a stale entry, not drop it") + } + if !rec.IsExpired() { + t.Error("entry should be reported stale") + } + // Peek must NOT evict — a second Peek still finds it. + if _, ok := m.Peek("stale"); !ok { + t.Error("Peek must not evict stale entries") + } +} + +func TestPeek_Missing(t *testing.T) { + m := newTestManager(t) + if _, ok := m.Peek("nope"); ok { + t.Error("Peek should report missing key as not found") + } +} From 13490fd6d94cb98ee0af0071ab629e5e4ed08968 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 31 May 2026 20:47:09 +0300 Subject: [PATCH 14/23] feat(070): registry cache freshness + refresh endpoint, surface key-absent (US4 FR-007/FR-008) Wire the cache primitives into the registry search path: SearchRegistryServers now caches each (registry,tag,query,limit) result and serves it while flagging age/staleness via contracts.RegistryCacheInfo. Add RefreshRegistryCache + POST /api/v1/registries/{id}/refresh to drop a registry's cached lists on demand. A key-requiring registry with no key configured surfaces as an 'unavailable' marker (HTTP 200) on both the REST search endpoint and the MCP search_servers tool instead of failing the surface (FR-008). Regenerate the OpenAPI spec for the new endpoint and response fields. Related #765 --- internal/contracts/types.go | 34 +++++- internal/httpapi/code_exec_test.go | 5 +- internal/httpapi/contracts_test.go | 7 +- internal/httpapi/registry_resilience_test.go | 114 +++++++++++++++++++ internal/httpapi/security_test.go | 5 +- internal/httpapi/server.go | 56 ++++++++- internal/runtime/runtime.go | 74 +++++++++++- internal/server/mcp.go | 17 +++ internal/server/server.go | 7 +- oas/docs.go | 4 +- oas/swagger.yaml | 64 +++++++++++ 11 files changed, 367 insertions(+), 20 deletions(-) create mode 100644 internal/httpapi/registry_resilience_test.go diff --git a/internal/contracts/types.go b/internal/contracts/types.go index ea3763dd..c72fbf8a 100644 --- a/internal/contracts/types.go +++ b/internal/contracts/types.go @@ -829,13 +829,37 @@ type GetRegistriesResponse struct { Total int `json:"total"` } +// RegistryCacheInfo describes how fresh a registry search result is. AgeSeconds +// is the age of the cached server list (0 for a freshly fetched result); Stale +// is true once the cache entry has passed its TTL but is still served pending a +// manual refresh (FR-007). +type RegistryCacheInfo struct { + AgeSeconds float64 `json:"age_seconds"` + Stale bool `json:"stale"` +} + +// RegistryUnavailable marks a registry that could not be queried — e.g. it +// requires an API key that is not configured. The overall search still +// succeeds; this block makes the registry's unavailability visible (FR-008). +type RegistryUnavailable struct { + Reason string `json:"reason"` +} + // SearchRegistryServersResponse is the response for GET /api/v1/registries/{id}/servers type SearchRegistryServersResponse struct { - RegistryID string `json:"registry_id"` - Servers []RepositoryServer `json:"servers"` - Total int `json:"total"` - Query string `json:"query,omitempty"` - Tag string `json:"tag,omitempty"` + RegistryID string `json:"registry_id"` + Servers []RepositoryServer `json:"servers"` + Total int `json:"total"` + Query string `json:"query,omitempty"` + Tag string `json:"tag,omitempty"` + Cache *RegistryCacheInfo `json:"cache,omitempty"` + Unavailable *RegistryUnavailable `json:"unavailable,omitempty"` +} + +// RefreshRegistryResponse is the response for POST /api/v1/registries/{id}/refresh. +type RefreshRegistryResponse struct { + RegistryID string `json:"registry_id"` + Cleared int `json:"cleared"` // number of cached entries dropped } // AddFromRegistryRequest is the optional POST body for adding an upstream from diff --git a/internal/httpapi/code_exec_test.go b/internal/httpapi/code_exec_test.go index 6d3a9b23..3f7e2980 100644 --- a/internal/httpapi/code_exec_test.go +++ b/internal/httpapi/code_exec_test.go @@ -90,9 +90,10 @@ func (m *mockController) GetTokenSavings() (interface{}, error) { return nil, nil } func (m *mockController) ListRegistries() ([]interface{}, error) { return nil, nil } -func (m *mockController) SearchRegistryServers(registryID, tag, query string, limit int) ([]interface{}, error) { - return nil, nil +func (m *mockController) SearchRegistryServers(registryID, tag, query string, limit int) ([]interface{}, *contracts.RegistryCacheInfo, error) { + return nil, nil, nil } +func (m *mockController) RefreshRegistryCache(registryID string) (int, error) { return 0, nil } func (m *mockController) AddServerFromRegistryRef(_ context.Context, _, _, _ string, _ map[string]string, _ *bool) (*config.ServerConfig, *contracts.RegistryAddError, error) { return nil, nil, nil } diff --git a/internal/httpapi/contracts_test.go b/internal/httpapi/contracts_test.go index f7749404..d1c94c53 100644 --- a/internal/httpapi/contracts_test.go +++ b/internal/httpapi/contracts_test.go @@ -305,8 +305,11 @@ func (m *MockServerController) CallTool(_ context.Context, _ string, _ map[strin func (m *MockServerController) ListRegistries() ([]interface{}, error) { return []interface{}{}, nil } -func (m *MockServerController) SearchRegistryServers(_, _, _ string, _ int) ([]interface{}, error) { - return []interface{}{}, nil +func (m *MockServerController) SearchRegistryServers(_, _, _ string, _ int) ([]interface{}, *contracts.RegistryCacheInfo, error) { + return []interface{}{}, nil, nil +} +func (m *MockServerController) RefreshRegistryCache(_ string) (int, error) { + return 0, nil } func (m *MockServerController) AddServerFromRegistryRef(_ context.Context, _, _, _ string, _ map[string]string, _ *bool) (*config.ServerConfig, *contracts.RegistryAddError, error) { return nil, nil, nil diff --git a/internal/httpapi/registry_resilience_test.go b/internal/httpapi/registry_resilience_test.go new file mode 100644 index 00000000..6627acd9 --- /dev/null +++ b/internal/httpapi/registry_resilience_test.go @@ -0,0 +1,114 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/registries" + "go.uber.org/zap/zaptest" +) + +// keyMissingController surfaces ErrRegistryKeyMissing from a registry search. +type keyMissingController struct { + *MockServerController +} + +func (c *keyMissingController) SearchRegistryServers(_, _, _ string, _ int) ([]interface{}, *contracts.RegistryCacheInfo, error) { + return nil, nil, registries.ErrRegistryKeyMissing +} + +// cachedController surfaces a freshness indicator alongside results. +type cachedController struct { + *MockServerController +} + +func (c *cachedController) SearchRegistryServers(_, _, _ string, _ int) ([]interface{}, *contracts.RegistryCacheInfo, error) { + return []interface{}{}, &contracts.RegistryCacheInfo{AgeSeconds: 42, Stale: true}, nil +} + +// refreshCountController reports a fixed number of cleared cache entries. +type refreshCountController struct { + *MockServerController +} + +func (c *refreshCountController) RefreshRegistryCache(_ string) (int, error) { return 3, nil } + +func decodeData(t *testing.T, w *httptest.ResponseRecorder, into interface{}) { + t.Helper() + var env struct { + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatalf("decode envelope: %v (body=%s)", err, w.Body.String()) + } + if !env.Success { + t.Fatalf("expected success envelope, got: %s", w.Body.String()) + } + if err := json.Unmarshal(env.Data, into); err != nil { + t.Fatalf("decode data: %v", err) + } +} + +// FR-008: a key-absent registry yields 200 with an unavailable marker, not 500. +func TestSearchRegistryServers_KeyMissingIsUnavailableNot500(t *testing.T) { + srv := NewServer(&keyMissingController{&MockServerController{}}, zaptest.NewLogger(t).Sugar(), nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/registries/needs-key/servers", http.NoBody) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String()) + } + var resp contracts.SearchRegistryServersResponse + decodeData(t, w, &resp) + if resp.Unavailable == nil || resp.Unavailable.Reason == "" { + t.Errorf("expected unavailable marker with reason, got %+v", resp.Unavailable) + } + if resp.Total != 0 { + t.Errorf("expected 0 servers, got %d", resp.Total) + } +} + +// FR-007: cache freshness is surfaced on the search response. +func TestSearchRegistryServers_CacheFreshnessSurfaced(t *testing.T) { + srv := NewServer(&cachedController{&MockServerController{}}, zaptest.NewLogger(t).Sugar(), nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/registries/pulse/servers", http.NoBody) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d", w.Code) + } + var resp contracts.SearchRegistryServersResponse + decodeData(t, w, &resp) + if resp.Cache == nil { + t.Fatal("expected cache freshness block") + } + if resp.Cache.AgeSeconds != 42 || !resp.Cache.Stale { + t.Errorf("cache info not surfaced verbatim: %+v", resp.Cache) + } +} + +// FR-007: the refresh endpoint reports how many cache entries were dropped. +func TestRefreshRegistryCache_Endpoint(t *testing.T) { + srv := NewServer(&refreshCountController{&MockServerController{}}, zaptest.NewLogger(t).Sugar(), nil) + req := httptest.NewRequest(http.MethodPost, "/api/v1/registries/pulse/refresh", http.NoBody) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String()) + } + var resp contracts.RefreshRegistryResponse + decodeData(t, w, &resp) + if resp.Cleared != 3 { + t.Errorf("expected cleared=3, got %d", resp.Cleared) + } + if resp.RegistryID != "pulse" { + t.Errorf("expected registry_id=pulse, got %q", resp.RegistryID) + } +} diff --git a/internal/httpapi/security_test.go b/internal/httpapi/security_test.go index c0748c17..427567b6 100644 --- a/internal/httpapi/security_test.go +++ b/internal/httpapi/security_test.go @@ -290,9 +290,10 @@ func (m *baseController) GetTokenSavings() (*contracts.ServerTokenMetrics, error func (m *baseController) ListRegistries() ([]interface{}, error) { return nil, nil } -func (m *baseController) SearchRegistryServers(registryID, query, tag string, limit int) ([]interface{}, error) { - return nil, nil +func (m *baseController) SearchRegistryServers(registryID, query, tag string, limit int) ([]interface{}, *contracts.RegistryCacheInfo, error) { + return nil, nil, nil } +func (m *baseController) RefreshRegistryCache(registryID string) (int, error) { return 0, nil } func (m *baseController) AddServerFromRegistryRef(_ context.Context, _, _, _ string, _ map[string]string, _ *bool) (*config.ServerConfig, *contracts.RegistryAddError, error) { return nil, nil, nil } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index cad43124..f8026bfa 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -25,6 +25,7 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/management" "github.com/smart-mcp-proxy/mcpproxy-go/internal/oauth" "github.com/smart-mcp-proxy/mcpproxy-go/internal/observability" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/registries" "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext" internalRuntime "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime" "github.com/smart-mcp-proxy/mcpproxy-go/internal/secret" @@ -115,7 +116,12 @@ type ServerController interface { // Registry browsing (Phase 7) ListRegistries() ([]interface{}, error) - SearchRegistryServers(registryID, tag, query string, limit int) ([]interface{}, error) + // SearchRegistryServers returns the registry's servers plus a cache + // freshness indicator (spec 070 FR-007). A registry requiring an + // unconfigured key surfaces as a wrapped registries.ErrRegistryKeyMissing. + SearchRegistryServers(registryID, tag, query string, limit int) ([]interface{}, *contracts.RegistryCacheInfo, error) + // RefreshRegistryCache drops a registry's cached server lists (FR-007). + RefreshRegistryCache(registryID string) (int, error) // AddServerFromRegistryRef resolves a registry reference server-side and // persists it quarantined (spec 070 keystone). On failure it returns a // stable cross-surface error code (*contracts.RegistryAddError) alongside @@ -625,6 +631,7 @@ func (s *Server) setupRoutes() { // Registry browsing (Phase 7) r.Get("/registries", s.handleListRegistries) r.Get("/registries/{id}/servers", s.handleSearchRegistryServers) + r.Post("/registries/{id}/refresh", s.handleRefreshRegistryCache) // spec 070 FR-007 r.Post("/registries/{id}/servers/{serverId}/add", s.handleAddFromRegistry) // spec 070 keystone add // Activity logging (RFC-003) @@ -3987,8 +3994,22 @@ func (s *Server) handleSearchRegistryServers(w http.ResponseWriter, r *http.Requ } } - servers, err := s.controller.SearchRegistryServers(registryID, tag, query, limit) + servers, cacheInfo, err := s.controller.SearchRegistryServers(registryID, tag, query, limit) if err != nil { + // FR-008: a registry that needs an unconfigured key is not an error — + // return an empty result marked unavailable so the overall search still + // succeeds and the unavailability is visible. + if errors.Is(err, registries.ErrRegistryKeyMissing) { + s.writeSuccess(w, contracts.SearchRegistryServersResponse{ + RegistryID: registryID, + Servers: []contracts.RepositoryServer{}, + Total: 0, + Query: query, + Tag: tag, + Unavailable: &contracts.RegistryUnavailable{Reason: err.Error()}, + }) + return + } s.logger.Error("Failed to search registry servers", "registry", registryID, "error", err) s.writeError(w, r, http.StatusInternalServerError, fmt.Sprintf("Failed to search servers: %v", err)) return @@ -4036,6 +4057,7 @@ func (s *Server) handleSearchRegistryServers(w http.ResponseWriter, r *http.Requ Total: len(contractServers), Query: query, Tag: tag, + Cache: cacheInfo, } s.writeSuccess(w, response) @@ -4098,6 +4120,36 @@ func (s *Server) handleAddFromRegistry(w http.ResponseWriter, r *http.Request) { }) } +// handleRefreshRegistryCache godoc +// @Summary Refresh a registry's cached server list +// @Description Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped. +// @Tags registries +// @Produce json +// @Param id path string true "Registry ID" +// @Success 200 {object} contracts.RefreshRegistryResponse "Registry cache refreshed" +// @Failure 400 {object} contracts.ErrorResponse "Registry ID is required" +// @Failure 500 {object} contracts.ErrorResponse "Failed to refresh registry cache" +// @Router /api/v1/registries/{id}/refresh [post] +func (s *Server) handleRefreshRegistryCache(w http.ResponseWriter, r *http.Request) { + registryID := chi.URLParam(r, "id") + if registryID == "" { + s.writeError(w, r, http.StatusBadRequest, "Registry ID is required") + return + } + + cleared, err := s.controller.RefreshRegistryCache(registryID) + if err != nil { + s.logger.Error("Failed to refresh registry cache", "registry", registryID, "error", err) + s.writeError(w, r, http.StatusInternalServerError, fmt.Sprintf("Failed to refresh registry cache: %v", err)) + return + } + + s.writeSuccess(w, contracts.RefreshRegistryResponse{ + RegistryID: registryID, + Cleared: cleared, + }) +} + // registryAddErrorStatus maps a stable add-from-registry error code to its HTTP // status (spec 070 contract). An unknown/empty code is an internal error. func registryAddErrorStatus(code string) int { diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 011c1b0d..3720ef84 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -1480,8 +1480,26 @@ func (r *Runtime) ListRegistries() ([]interface{}, error) { return result, nil } -// SearchRegistryServers searches for servers in a specific registry (Phase 7) -func (r *Runtime) SearchRegistryServers(registryID, tag, query string, limit int) ([]interface{}, error) { +// registryServersCachePrefix is the stable cache-key prefix for a registry's +// cached server lists. A single RefreshRegistryCache drops every tag/query/limit +// variant under it (FR-007). +func registryServersCachePrefix(registryID string) string { + return fmt.Sprintf("registry-servers:%s:", registryID) +} + +// registryServersCacheKey keys a specific (registry, tag, query, limit) search. +func registryServersCacheKey(registryID, tag, query string, limit int) string { + return fmt.Sprintf("%s%s:%s:%d", registryServersCachePrefix(registryID), tag, query, limit) +} + +// SearchRegistryServers searches for servers in a specific registry (Phase 7). +// Results are cached per (registry, tag, query, limit) via the cache manager; +// a cached list is served while flagging its freshness (FR-007), and the +// returned *contracts.RegistryCacheInfo carries the age/stale indicator. A +// registry that requires an unconfigured API key surfaces as a wrapped +// registries.ErrRegistryKeyMissing so the caller can mark it unavailable +// without failing the overall search (FR-008). +func (r *Runtime) SearchRegistryServers(registryID, tag, query string, limit int) ([]interface{}, *contracts.RegistryCacheInfo, error) { r.mu.RLock() cfg := r.cfg r.mu.RUnlock() @@ -1495,6 +1513,26 @@ func (r *Runtime) SearchRegistryServers(registryID, tag, query string, limit int // Initialize registries from config registries.SetRegistriesFromConfig(cfg) + cacheKey := registryServersCacheKey(registryID, tag, query, limit) + + // Serve a cached server list when present, flagging its age/freshness. + if r.cacheManager != nil { + if rec, ok := r.cacheManager.Peek(cacheKey); ok { + var cached []interface{} + if err := json.Unmarshal([]byte(rec.FullContent), &cached); err == nil { + info := &contracts.RegistryCacheInfo{ + AgeSeconds: time.Since(rec.CreatedAt).Seconds(), + Stale: rec.IsExpired(), + } + r.logger.Debug("Registry search served from cache", + zap.String("registry_id", registryID), + zap.Float64("age_seconds", info.AgeSeconds), + zap.Bool("stale", info.Stale)) + return cached, info, nil + } + } + } + // Create a guesser for repository detection (with caching) guesser := experiments.NewGuesser(r.cacheManager, r.logger) @@ -1504,7 +1542,7 @@ func (r *Runtime) SearchRegistryServers(registryID, tag, query string, limit int servers, err := registries.SearchServers(ctx, registryID, tag, query, limit, guesser) if err != nil { - return nil, fmt.Errorf("failed to search registry: %w", err) + return nil, nil, fmt.Errorf("failed to search registry: %w", err) } // Convert to interface slice @@ -1538,11 +1576,39 @@ func (r *Runtime) SearchRegistryServers(registryID, tag, query string, limit int result[i] = serverMap } + // Cache the freshly fetched list so subsequent searches surface its age. + var cacheInfo *contracts.RegistryCacheInfo + if r.cacheManager != nil { + if data, mErr := json.Marshal(result); mErr == nil { + if sErr := r.cacheManager.Store(cacheKey, "registry-servers", nil, string(data), "", len(result)); sErr != nil { + r.logger.Warn("Failed to cache registry search", zap.Error(sErr)) + } + } + cacheInfo = &contracts.RegistryCacheInfo{AgeSeconds: 0, Stale: false} + } + r.logger.Info("Registry search completed", zap.String("registry_id", registryID), zap.Int("results", len(result))) - return result, nil + return result, cacheInfo, nil +} + +// RefreshRegistryCache invalidates all cached server lists for a registry, +// forcing the next search to re-fetch from the source (FR-007). Returns the +// number of cache entries dropped. +func (r *Runtime) RefreshRegistryCache(registryID string) (int, error) { + if r.cacheManager == nil { + return 0, nil + } + cleared, err := r.cacheManager.InvalidatePrefix(registryServersCachePrefix(registryID)) + if err != nil { + return 0, fmt.Errorf("failed to refresh registry cache: %w", err) + } + r.logger.Info("Registry cache refreshed", + zap.String("registry_id", registryID), + zap.Int("cleared", cleared)) + return cleared, nil } // GetDockerRecoveryStatus returns the current Docker recovery status from the upstream manager diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 7d236622..7e2b573f 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -909,6 +909,23 @@ func (p *MCPProxyServer) handleSearchServers(ctx context.Context, request mcp.Ca // Search for servers servers, err := registries.SearchServers(ctx, registry, tag, search, limit, guesser) if err != nil { + // FR-008: a registry that requires an unconfigured key is reported as + // unavailable (not a hard error) so an agent's search still succeeds and + // the reason is visible. + if errors.Is(err, registries.ErrRegistryKeyMissing) { + response := map[string]interface{}{ + "servers": []interface{}{}, + "registry": registry, + "total": 0, + "query": search, + "tag": tag, + "unavailable": map[string]interface{}{"reason": err.Error()}, + "message": fmt.Sprintf("Registry '%s' is unavailable: %v", registry, err), + } + jsonResult, _ := json.Marshal(response) + p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), args, response, nil, "") + return mcp.NewToolResultText(string(jsonResult)), nil + } p.logger.Error("Registry search failed", zap.String("registry", registry), zap.String("search", search), diff --git a/internal/server/server.go b/internal/server/server.go index e2cc45db..42ad1e28 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2436,10 +2436,15 @@ func (s *Server) ListRegistries() ([]interface{}, error) { } // SearchRegistryServers searches for servers in a specific registry (Phase 7) -func (s *Server) SearchRegistryServers(registryID, tag, query string, limit int) ([]interface{}, error) { +func (s *Server) SearchRegistryServers(registryID, tag, query string, limit int) ([]interface{}, *contracts.RegistryCacheInfo, error) { return s.runtime.SearchRegistryServers(registryID, tag, query, limit) } +// RefreshRegistryCache invalidates a registry's cached server lists (spec 070 FR-007). +func (s *Server) RefreshRegistryCache(registryID string) (int, error) { + return s.runtime.RefreshRegistryCache(registryID) +} + // GetVersionInfo returns the current version information from the update checker. func (s *Server) GetVersionInfo() *updatecheck.VersionInfo { return s.runtime.GetVersionInfo() diff --git a/oas/docs.go b/oas/docs.go index 3668141c..323b62d0 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -6,10 +6,10 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"config.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_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"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.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.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.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.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_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"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.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"auto_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.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.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.RefreshRegistryResponse":{"properties":{"cleared":{"description":"number of cached entries dropped","type":"integer"},"registry_id":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"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"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"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/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"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"]}}}, + "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"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"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}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"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"]}}}, "openapi": "3.1.0" }` diff --git a/oas/swagger.yaml b/oas/swagger.yaml index 8056c575..b068fe19 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -444,6 +444,12 @@ components: type: string protocol: type: string + requires_key: + description: |- + RequiresKey marks a registry that needs an API key to be queried. When + true and no key is configured, the registry is skipped/marked unavailable + rather than failing the whole search (FR-008). + type: boolean servers_url: type: string tags: @@ -1449,6 +1455,14 @@ components: description: Number of newly discovered tools awaiting approval type: integer type: object + contracts.RefreshRegistryResponse: + properties: + cleared: + description: number of cached entries dropped + type: integer + registry_id: + type: string + type: object contracts.Registry: properties: count: @@ -1472,6 +1486,18 @@ components: url: type: string type: object + contracts.RegistryCacheInfo: + properties: + age_seconds: + type: number + stale: + type: boolean + type: object + contracts.RegistryUnavailable: + properties: + reason: + type: string + type: object contracts.ReplayToolCallRequest: properties: arguments: @@ -1532,6 +1558,8 @@ components: type: object contracts.SearchRegistryServersResponse: properties: + cache: + $ref: '#/components/schemas/contracts.RegistryCacheInfo' query: type: string registry_id: @@ -1545,6 +1573,8 @@ components: type: string total: type: integer + unavailable: + $ref: '#/components/schemas/contracts.RegistryUnavailable' type: object contracts.SearchResult: properties: @@ -3277,6 +3307,40 @@ paths: summary: List available MCP server registries tags: - registries + /api/v1/registries/{id}/refresh: + post: + description: Invalidates the cached server lists for a registry so the next + search re-fetches fresh data from the source (spec 070 FR-007). Returns how + many cache entries were dropped. + parameters: + - description: Registry ID + in: path + name: id + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.RefreshRegistryResponse' + description: Registry cache refreshed + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Registry ID is required + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Failed to refresh registry cache + summary: Refresh a registry's cached server list + tags: + - registries /api/v1/registries/{id}/servers: get: description: Searches for MCP servers within a specific registry by keyword From b46a3ae9acc527072e4fabbbec776be811c3b509 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 31 May 2026 20:47:31 +0300 Subject: [PATCH 15/23] docs(070): mark US4 tasks T018-T020 complete Related #765 --- specs/070-registry-easy-upstream-add/tasks.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specs/070-registry-easy-upstream-add/tasks.md b/specs/070-registry-easy-upstream-add/tasks.md index 917a23c7..3ec48609 100644 --- a/specs/070-registry-easy-upstream-add/tasks.md +++ b/specs/070-registry-easy-upstream-add/tasks.md @@ -80,9 +80,9 @@ Web app: Go backend (`internal/`, `cmd/`) + embedded Vue frontend (`frontend/src **Goal**: merge-with-defaults, cache freshness/refresh, key-absent skip. **Independent test**: per quickstart US4. -- [ ] T018 [P] [US4] Change `SetRegistriesFromConfig` to MERGE built-in defaults ∪ config by ID (`internal/registries/registry_data.go:10-42`) + unit test asserting custom entry doesn't drop the 5 defaults (FR-006). -- [ ] T019 [P] [US4] Add `Refresh`/`Invalidate` + age/`stale` to `internal/cache/manager.go`; surface `cache:{age_seconds,stale}` on `GET /registries/{id}/servers` and add `POST /api/v1/registries/{id}/refresh` (FR-007). -- [ ] T020 [US4] Add `RequiresKey` to registry entry + skip/mark `unavailable:{reason}` when key absent without failing overall search (`internal/registries/search.go`); unit test (FR-008/SC-006). +- [X] T018 [P] [US4] Change `SetRegistriesFromConfig` to MERGE built-in defaults ∪ config by ID (`internal/registries/registry_data.go:10-42`) + unit test asserting custom entry doesn't drop the 5 defaults (FR-006). +- [X] T019 [P] [US4] Add `Refresh`/`Invalidate` + age/`stale` to `internal/cache/manager.go`; surface `cache:{age_seconds,stale}` on `GET /registries/{id}/servers` and add `POST /api/v1/registries/{id}/refresh` (FR-007). +- [X] T020 [US4] Add `RequiresKey` to registry entry + skip/mark `unavailable:{reason}` when key absent without failing overall search (`internal/registries/search.go`); unit test (FR-008/SC-006). --- From 4f7bc26ed294a11114fd8152e5c978922696a37a Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Sun, 31 May 2026 21:06:12 +0300 Subject: [PATCH 16/23] fix(070): copy-on-write config in AddServer to avoid data race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AddServer mutated the live immutable config snapshot returned by runtime.Config() in place (currentConfig.Servers = append(...)) before publishing via UpdateConfig. Background goroutines that range cfg.Servers concurrently — LoadConfiguredServers during startup and the 2s-delayed DiscoverAndIndexTools — raced that write, which the -race detector flagged on TestHandleUpstreamServers_AddFromRegistry_HappyPath in CI. Clone the config and its server slice, append to the clone, then publish the new snapshot atomically. Behaviour is unchanged; readers holding the prior snapshot are now isolated, as the immutable-snapshot contract intends. Related #555 --- internal/server/server.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index 42ad1e28..283e7e50 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1076,11 +1076,17 @@ func (s *Server) AddServer(ctx context.Context, serverConfig *config.ServerConfi return fmt.Errorf("failed to save server to storage: %w", err) } - // Update runtime config + // Update runtime config. + // runtime.Config() returns the live immutable snapshot, which background + // goroutines (e.g. LoadConfiguredServers, DiscoverAndIndexTools) may be + // ranging over concurrently. Mutating its Servers slice in place is a data + // race, so copy-on-write: clone the config and its server list, append to + // the clone, then publish atomically via UpdateConfig. currentConfig := s.runtime.Config() if currentConfig != nil { - currentConfig.Servers = append(currentConfig.Servers, serverConfig) - s.runtime.UpdateConfig(currentConfig, "") + updatedConfig := *currentConfig + updatedConfig.Servers = append(append([]*config.ServerConfig(nil), currentConfig.Servers...), serverConfig) + s.runtime.UpdateConfig(&updatedConfig, "") } // Save configuration to file From 59efb9c0e3ecb9ed53fc8f0a2efc346c561c1522 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Mon, 1 Jun 2026 06:08:44 +0300 Subject: [PATCH 17/23] fix(upstream): route concurrent Config reads through GetConfig (MCP-770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DiscoverTools snapshotted client.Config directly while only holding the Manager's m.mu, but the reconcile add path (AddServerConfig) releases m.mu before calling managed.Client.SetConfig, which guards mc.Config with the client's own mc.mu. The two accesses therefore used different mutexes, producing a data race the -race detector flagged intermittently in CI (PR #555, run 26719908592). Fix the read side: read config via the mutex-guarded GetConfig() accessor in DiscoverTools and in the API-facing status readers (GetStats, GetTotalToolCount, ListServers) that share the identical hazard — they read client.Config after releasing m.mu and run concurrently with reconcile. mc.mu is a leaf lock (only Get/SetConfig take it, never while holding m.mu), so this is deadlock-safe. Add TestDiscoverTools_ConfigRace: drives AddServerConfig (SetConfig) against the readers concurrently; trips -race before the fix, green after. Related: MCP-770 --- internal/upstream/manager.go | 38 ++++++++--- internal/upstream/manager_config_race_test.go | 63 +++++++++++++++++++ 2 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 internal/upstream/manager_config_race_test.go diff --git a/internal/upstream/manager.go b/internal/upstream/manager.go index b66c9895..7d1bd3d4 100644 --- a/internal/upstream/manager.go +++ b/internal/upstream/manager.go @@ -822,14 +822,21 @@ func (m *Manager) DiscoverTools(ctx context.Context) ([]*config.ToolMetadata, er for id, client := range m.clients { name := "" quarantined := false - if client != nil && client.Config != nil { - name = client.Config.Name - quarantined = client.Config.Quarantined + enabled := false + // Read config through the thread-safe accessor (mc.mu) — the reconcile + // add path (AddServerConfig) calls SetConfig while holding only mc.mu, + // not m.mu, so a direct client.Config field read races with it (MCP-770). + if client != nil { + if cfg := client.GetConfig(); cfg != nil { + name = cfg.Name + quarantined = cfg.Quarantined + enabled = cfg.Enabled + } } snapshots = append(snapshots, clientSnapshot{ id: id, name: name, - enabled: client != nil && client.Config != nil && client.Config.Enabled, + enabled: enabled, quarantined: quarantined, client: client, }) @@ -1312,15 +1319,22 @@ func (m *Manager) GetStats() map[string]interface{} { // Get detailed connection info from state manager connectionInfo := client.GetConnectionInfo() + // Read config through the thread-safe accessor to avoid racing with + // SetConfig on the reconcile add path (MCP-770). + name, url, protocol := "", "", "" + if cfg := client.GetConfig(); cfg != nil { + name, url, protocol = cfg.Name, cfg.URL, cfg.Protocol + } + status := map[string]interface{}{ "state": connectionInfo.State.String(), "connected": connectionInfo.State == types.StateReady, "connecting": client.IsConnecting(), "retry_count": connectionInfo.RetryCount, "should_retry": client.ShouldRetry(), - "name": client.Config.Name, - "url": client.Config.URL, - "protocol": client.Config.Protocol, + "name": name, + "url": url, + "protocol": protocol, } if connectionInfo.State == types.StateReady { @@ -1386,7 +1400,12 @@ func (m *Manager) GetTotalToolCount() int { // Now process clients without holding lock totalTools := 0 for _, client := range clientsCopy { - if client == nil || client.Config == nil || !client.Config.Enabled || !client.IsConnected() { + if client == nil { + continue + } + // Read config through the thread-safe accessor (MCP-770). + cfg := client.GetConfig() + if cfg == nil || !cfg.Enabled || !client.IsConnected() { continue } @@ -1403,7 +1422,8 @@ func (m *Manager) ListServers() map[string]*config.ServerConfig { servers := make(map[string]*config.ServerConfig) for id, client := range m.clients { - servers[id] = client.Config + // Read config through the thread-safe accessor (MCP-770). + servers[id] = client.GetConfig() } return servers } diff --git a/internal/upstream/manager_config_race_test.go b/internal/upstream/manager_config_race_test.go new file mode 100644 index 00000000..73361642 --- /dev/null +++ b/internal/upstream/manager_config_race_test.go @@ -0,0 +1,63 @@ +package upstream + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// TestDiscoverTools_ConfigRace reproduces MCP-770: a data race between +// Manager.DiscoverTools (background tool indexing) reading client.Config and +// managed.Client.SetConfig (reconcile add path in AddServerConfig) writing it. +// +// AddServerConfig releases m.mu before calling SetConfig (to avoid deadlock with +// GetServerState), so the write is guarded only by the managed client's mc.mu. +// DiscoverTools must therefore read the config through the mutex-guarded +// GetConfig() accessor rather than touching client.Config directly. Run under +// `go test -race` — without the fix the race detector flags concurrent +// read/write on the mc.Config field. +func TestDiscoverTools_ConfigRace(t *testing.T) { + serverConfig := &config.ServerConfig{ + Name: "race-server", + URL: "http://127.0.0.1:0", + Protocol: "http", + Enabled: true, + Created: time.Now(), + } + + manager, _ := createTestManagerWithClient(t, serverConfig) + + const iterations = 200 + var wg sync.WaitGroup + wg.Add(2) + + // Writer: reconcile add path -> SetConfig swaps the mc.Config pointer. + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + // Fresh, equal config each iteration so the unchanged-config branch + // in AddServerConfig calls SetConfig with a new pointer. + cfg := *serverConfig + cfg.Created = time.Now() + _ = manager.AddServerConfig(serverConfig.Name, &cfg) + } + }() + + // Reader: background tool indexing + API-facing status readers snapshot + // client.Config. All must go through the mutex-guarded accessor. + go func() { + defer wg.Done() + ctx := context.Background() + for i := 0; i < iterations; i++ { + _, _ = manager.DiscoverTools(ctx) + _ = manager.GetStats() + _ = manager.GetTotalToolCount() + _ = manager.ListServers() + } + }() + + wg.Wait() +} From 98d02eb12b059661d0b47fd2b474cba814fbfecf Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Mon, 1 Jun 2026 06:52:22 +0300 Subject: [PATCH 18/23] fix(upstream): make managed.Client config access atomic (MCP-770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first MCP-770 fix routed Manager-side reads (DiscoverTools, GetStats, GetTotalToolCount, ListServers) through GetConfig(), but the underlying hazard is broader: managed.Client.Config is a pointer field that SetConfig swaps off m.mu (reconcile add path), while it is read concurrently from many sites that do NOT hold the client's mc.mu — including Client.Connect's unlocked phase (client.go:197) and detached state-change callback goroutines (onStateChange, spawned by StateManager.SetError). The CI -race detector flagged the Connect-vs-SetConfig variant on PR #555's macOS unit job after the first fix was cherry-picked. A mutex-guarded accessor can't cover this: most internal readers run while mc.mu is already held, so routing them through an RLock GetConfig() would deadlock (sync.RWMutex is not reentrant). Instead make the field itself lock-free and data-race-free: - Replace the exported `Config *config.ServerConfig` field with an unexported `cfg atomic.Pointer[config.ServerConfig]`. - GetConfig() returns cfg.Load(); SetConfig() does cfg.Store(). Both are lock-free and safe whether or not mc.mu is held. - Route every reader through GetConfig() across the managed client, the upstream Manager, and the supervisor actor pool. Add TestConnect_ConfigRace: drives AddServerConfig (SetConfig) against Client.Connect concurrently; trips -race on the old field, green after. Verified: go test -race ./internal/upstream/... ./internal/runtime/... and the original CI victims TestHandleUpstreamServers_AddFromRegistry_HappyPath / _AdminAllowed_WriteOps pass x5 under -race; build + linter clean. Related: MCP-770 --- internal/runtime/supervisor/actor_pool.go | 6 +- .../actor_pool_complex_reference.go | 26 +-- internal/upstream/client_test.go | 2 +- internal/upstream/managed/client.go | 178 ++++++++++-------- internal/upstream/managed/health_flap_test.go | 2 +- .../managed/listtools_coalescing_test.go | 2 +- internal/upstream/manager.go | 62 +++--- internal/upstream/manager_config_race_test.go | 47 +++++ 8 files changed, 192 insertions(+), 133 deletions(-) diff --git a/internal/runtime/supervisor/actor_pool.go b/internal/runtime/supervisor/actor_pool.go index 5cdc3c24..1ad6e2e1 100644 --- a/internal/runtime/supervisor/actor_pool.go +++ b/internal/runtime/supervisor/actor_pool.go @@ -211,12 +211,12 @@ func (p *ActorPoolSimple) GetAllStates() map[string]*ServerState { state := &ServerState{ Name: name, - Config: client.Config, - Enabled: client.Config.Enabled, + Config: client.GetConfig(), + Enabled: client.GetConfig().Enabled, Connected: connected, } - if client.Config.Quarantined { + if client.GetConfig().Quarantined { state.Quarantined = true } diff --git a/internal/runtime/supervisor/actor_pool_complex_reference.go b/internal/runtime/supervisor/actor_pool_complex_reference.go index 2599332b..24b26abe 100644 --- a/internal/runtime/supervisor/actor_pool_complex_reference.go +++ b/internal/runtime/supervisor/actor_pool_complex_reference.go @@ -16,10 +16,10 @@ import ( // ActorPool manages the lifecycle of server actors and provides stats for Supervisor. // This replaces UpstreamAdapter with direct Actor integration (Phase 7.2). type ActorPool struct { - actors map[string]*actor.Actor - mu sync.RWMutex - logger *zap.Logger - manager *upstream.Manager // Use existing manager for client creation + actors map[string]*actor.Actor + mu sync.RWMutex + logger *zap.Logger + manager *upstream.Manager // Use existing manager for client creation // Event aggregation eventCh chan Event @@ -218,12 +218,12 @@ func (p *ActorPool) GetServerState(name string) (*ServerState, error) { state := &ServerState{ Name: name, - Config: client.Config, - Enabled: client.Config.Enabled, + Config: client.GetConfig(), + Enabled: client.GetConfig().Enabled, Connected: client.IsConnected(), } - if client.Config.Quarantined { + if client.GetConfig().Quarantined { state.Quarantined = true } @@ -258,12 +258,12 @@ func (p *ActorPool) GetAllStates() map[string]*ServerState { connected := client.IsConnected() state := &ServerState{ Name: name, - Config: client.Config, - Enabled: client.Config.Enabled, + Config: client.GetConfig(), + Enabled: client.GetConfig().Enabled, Connected: connected, } - if client.Config.Quarantined { + if client.GetConfig().Quarantined { state.Quarantined = true } @@ -328,9 +328,9 @@ func (p *ActorPool) forwardActorEvents(name string, a *actor.Actor) { ServerName: name, Timestamp: event.Timestamp, Payload: map[string]interface{}{ - "connected": event.State == actor.StateConnected, - "state": string(event.State), - "actor_event": string(event.Type), + "connected": event.State == actor.StateConnected, + "state": string(event.State), + "actor_event": string(event.Type), }, }) } diff --git a/internal/upstream/client_test.go b/internal/upstream/client_test.go index 93cf8391..101dfa49 100644 --- a/internal/upstream/client_test.go +++ b/internal/upstream/client_test.go @@ -309,7 +309,7 @@ func TestClient_Headers_Support(t *testing.T) { require.NotNil(t, client) // Test that headers are stored in config - assert.Equal(t, tt.headers, client.Config.Headers) + assert.Equal(t, tt.headers, client.GetConfig().Headers) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() diff --git a/internal/upstream/managed/client.go b/internal/upstream/managed/client.go index d4c65db5..eee83ec9 100644 --- a/internal/upstream/managed/client.go +++ b/internal/upstream/managed/client.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" "sync" + "sync/atomic" "time" "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" @@ -20,8 +21,15 @@ import ( // Client wraps a core client with state management, concurrency control, and background recovery type Client struct { - id string - Config *config.ServerConfig // Public field for compatibility with existing code + id string + // cfg holds the server configuration as an atomic pointer. SetConfig swaps it + // (reconcile add path, off mc.mu) while many readers — including detached + // state-change callback goroutines and Connect's unlocked phase — read it + // concurrently. An atomic pointer makes every read/write data-race-free and + // is lock-free, so it is safe to read whether or not mc.mu is held (the RLock + // accessor approach would deadlock the in-lock readers). Access via + // GetConfig() / SetConfig() only — never touch the field directly. (MCP-770) + cfg atomic.Pointer[config.ServerConfig] coreClient *core.Client logger *zap.Logger StateManager *types.StateManager // Public field for callback access @@ -91,7 +99,6 @@ func NewClient(id string, serverConfig *config.ServerConfig, logger *zap.Logger, // Create managed client mc := &Client{ id: id, - Config: serverConfig, coreClient: coreClient, logger: logger.With(zap.String("component", "managed_client")), StateManager: types.NewStateManager(), @@ -100,6 +107,7 @@ func NewClient(id string, serverConfig *config.ServerConfig, logger *zap.Logger, storage: storage, stopMonitoring: make(chan struct{}), } + mc.cfg.Store(serverConfig) // Set up state change callback mc.StateManager.SetStateChangeCallback(mc.onStateChange) @@ -152,8 +160,14 @@ func (mc *Client) Connect(ctx context.Context) error { return fmt.Errorf("connection already in progress or established (state: %s)", mc.StateManager.GetState().String()) } + // Snapshot the server name while mc.mu is held. Phase 3 below runs WITHOUT + // mc.mu, so dereferencing mc.GetConfig() there races with SetConfig swapping the + // pointer under the lock (MCP-770: SetConfig vs Connect). Use this local for + // any logging in the unlocked window. + serverName := mc.GetConfig().Name + mc.logger.Info("Starting managed connection to upstream server", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.String("current_state", mc.StateManager.GetState().String()), zap.Bool("list_tools_in_progress", mc.listToolsInProgress)) @@ -164,11 +178,11 @@ func (mc *Client) Connect(ctx context.Context) error { currentState := mc.StateManager.GetState() if currentState == types.StateError || currentState == types.StateDisconnected { mc.logger.Debug("Disconnecting core client before reconnect to clear stale state", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.String("from_state", currentState.String())) if err := mc.coreClient.Disconnect(); err != nil { mc.logger.Debug("Core client disconnect before reconnect returned", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Error(err)) } } @@ -194,7 +208,7 @@ func (mc *Client) Connect(ctx context.Context) error { // Phase 3: Execute the actual connection (potentially slow - OAuth, MCP initialize) // mc.mu is NOT held here, so Disconnect/SetConfig/GetConfig won't block mc.logger.Debug("Invoking core client Connect for managed client", - zap.String("server", mc.Config.Name)) + zap.String("server", serverName)) connectErr := mc.coreClient.Connect(connectCtx) // Phase 4: Re-acquire lock to update state based on result @@ -205,7 +219,7 @@ func (mc *Client) Connect(ctx context.Context) error { // Check if this is a deferred OAuth requirement (pending user action) if core.IsOAuthPending(connectErr) { mc.logger.Info("⏳ OAuth authentication pending user action", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) // Transition to PendingAuth state instead of Error mc.StateManager.TransitionTo(types.StatePendingAuth) mc.StateManager.SetError(connectErr) @@ -216,7 +230,7 @@ func (mc *Client) Connect(ctx context.Context) error { // Check if this is a token refresh scenario vs full re-auth isRefreshScenario := mc.isTokenRefreshScenario(connectErr) mc.logger.Info("🎯 OAuth authorization required during MCP initialization", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Bool("token_refresh_scenario", isRefreshScenario)) // Don't apply backoff for OAuth authorization requirement mc.StateManager.SetError(connectErr) @@ -225,7 +239,7 @@ func (mc *Client) Connect(ctx context.Context) error { // Check if this is a token refresh scenario vs full re-auth isRefreshScenario := mc.isTokenRefreshScenario(connectErr) mc.logger.Warn("OAuth authentication failed, applying extended backoff", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Bool("token_refresh_scenario", isRefreshScenario), zap.Error(connectErr)) mc.StateManager.SetOAuthError(connectErr) @@ -236,7 +250,7 @@ func (mc *Client) Connect(ctx context.Context) error { } mc.logger.Debug("Core client Connect returned successfully", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) // Transition to ready state only if not already ready if mc.StateManager.GetState() != types.StateReady { @@ -254,11 +268,11 @@ func (mc *Client) Connect(ctx context.Context) error { } mc.logger.Info("Successfully established managed connection", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) // Add a small delay before starting background monitoring to let connection stabilize mc.logger.Debug("🔍 Adding stabilization delay before starting background monitoring", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) // Create cancellable context for monitoring startup monitoringCtx, monitoringCancel := context.WithCancel(context.Background()) @@ -271,13 +285,13 @@ func (mc *Client) Connect(ctx context.Context) error { mc.mu.Lock() if mc.monitoringCancelFunc != nil { mc.logger.Debug("🔍 Starting background monitoring after stabilization delay", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) mc.startBackgroundMonitoring() } mc.mu.Unlock() case <-monitoringCtx.Done(): mc.logger.Debug("🔍 Background monitoring startup cancelled", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) } }() @@ -292,7 +306,7 @@ func (mc *Client) Disconnect() error { mc.mu.Lock() defer mc.mu.Unlock() - mc.logger.Info("Disconnecting managed client", zap.String("server", mc.Config.Name)) + mc.logger.Info("Disconnecting managed client", zap.String("server", mc.GetConfig().Name)) // Ensure no ListTools operations remain after acquiring the lock mc.cancelInFlightListTools() @@ -315,7 +329,7 @@ func (mc *Client) Disconnect() error { mc.StateManager.Reset() mc.logger.Debug("Managed client disconnect complete", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Bool("list_tools_in_progress", mc.listToolsInProgress)) return nil @@ -341,18 +355,16 @@ func (mc *Client) GetConnectionInfo() types.ConnectionInfo { return mc.StateManager.GetConnectionInfo() } -// GetConfig returns a thread-safe copy of the server configuration +// GetConfig returns the current server configuration pointer in a thread-safe, +// lock-free manner. Safe to call whether or not mc.mu is held. func (mc *Client) GetConfig() *config.ServerConfig { - mc.mu.RLock() - defer mc.mu.RUnlock() - return mc.Config + return mc.cfg.Load() } -// SetConfig updates the server configuration in a thread-safe manner +// SetConfig atomically swaps the server configuration. Lock-free; callers must +// not hold mc.mu (they don't need to — the swap is atomic). func (mc *Client) SetConfig(config *config.ServerConfig) { - mc.mu.Lock() - defer mc.mu.Unlock() - mc.Config = config + mc.cfg.Store(config) } // GetServerInfo returns server information @@ -409,11 +421,11 @@ func (mc *Client) IsDockerIsolated() bool { return false } // Check if server has isolation explicitly disabled - if mc.Config.Isolation != nil && mc.Config.Isolation.Enabled != nil && !*mc.Config.Isolation.Enabled { + if mc.GetConfig().Isolation != nil && mc.GetConfig().Isolation.Enabled != nil && !*mc.GetConfig().Isolation.Enabled { return false } // Only stdio servers with commands get Docker-isolated - return mc.Config.Command != "" + return mc.GetConfig().Command != "" } // SetUserLoggedOut marks that the user has explicitly logged out @@ -494,13 +506,13 @@ func (mc *Client) publishListToolsResult(tools []*config.ToolMetadata, err error // callers onto a single in-flight upstream call. func (mc *Client) ListTools(ctx context.Context) ([]*config.ToolMetadata, error) { mc.logger.Debug("🔍 ListTools called", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.String("state", mc.StateManager.GetState().String()), zap.Bool("connected", mc.IsConnected())) if !mc.IsConnected() { mc.logger.Debug("🔍 ListTools rejected - client not connected", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.String("state", mc.StateManager.GetState().String())) return nil, fmt.Errorf("client not connected (state: %s)", mc.StateManager.GetState().String()) } @@ -525,11 +537,11 @@ func (mc *Client) ListTools(ctx context.Context) ([]*config.ToolMetadata, error) // Defensive fallback: every leader path is supposed to allocate a // wait channel via acquireListToolsContext, so this should be // unreachable. Fail fast rather than block forever on a nil channel. - return nil, fmt.Errorf("ListTools operation already in progress for server %s", mc.Config.Name) + return nil, fmt.Errorf("ListTools operation already in progress for server %s", mc.GetConfig().Name) } mc.logger.Debug("🔍 ListTools already in progress, waiting for shared result", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) select { case <-ctx.Done(): @@ -554,10 +566,10 @@ func (mc *Client) runListToolsAsLeader(listCtx context.Context, release func() b defer func() { if release() { mc.logger.Debug("🔍 ListTools operation completed, flag reset", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) } else { mc.logger.Debug("🔍 ListTools operation completed while disconnected", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) } }() @@ -566,12 +578,12 @@ func (mc *Client) runListToolsAsLeader(listCtx context.Context, release func() b if err != nil { mc.logger.Error("ListTools operation failed", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Error(err)) if mc.isConnectionError(err) { mc.logger.Warn("Connection error detected during ListTools, updating server state", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Error(err)) mc.StateManager.SetError(err) } @@ -595,13 +607,13 @@ func (mc *Client) CallTool(ctx context.Context, toolName string, args map[string // Use different log levels based on error type if mc.isNormalReconnectionError(err) { mc.logger.Warn("Tool call failed due to connection loss, will attempt reconnection", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.String("tool", toolName), zap.String("error_type", "normal_reconnection"), zap.Error(err)) } else { mc.logger.Error("Tool call failed with connection error", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.String("tool", toolName), zap.Error(err)) } @@ -609,7 +621,7 @@ func (mc *Client) CallTool(ctx context.Context, toolName string, args map[string } else { // Log non-connection errors at error level mc.logger.Error("Tool call failed", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.String("tool", toolName), zap.Error(err)) } @@ -630,7 +642,7 @@ func (mc *Client) cancelInFlightListTools() { } mc.logger.Debug("Cancelling in-flight ListTools operation", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) cancel() @@ -649,7 +661,7 @@ func (mc *Client) cancelInFlightListTools() { } mc.logger.Debug("Timed out waiting for ListTools operation to cancel", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) } // cancelInFlightConnect cancels any in-flight Connect() operation. @@ -665,7 +677,7 @@ func (mc *Client) cancelInFlightConnect() { } mc.logger.Debug("Cancelling in-flight Connect operation", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) cancel() } @@ -674,15 +686,15 @@ func (mc *Client) onStateChange(oldState, newState types.ConnectionState, info * mc.logger.Info("State transition", zap.String("from", oldState.String()), zap.String("to", newState.String()), - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) // Handle error states with appropriate log levels if newState == types.StateError && info.LastError != nil { // Check for deprecated endpoint errors first - these require URL changes, not reconnection if mc.isDeprecatedEndpointError(info.LastError) { mc.logger.Error("⚠️ ENDPOINT DEPRECATED: Server URL needs to be updated", - zap.String("server", mc.Config.Name), - zap.String("current_url", mc.Config.URL), + zap.String("server", mc.GetConfig().Name), + zap.String("current_url", mc.GetConfig().URL), zap.String("error_type", "endpoint_deprecated"), zap.String("action", "Update the server URL in your configuration"), zap.String("hint", "The server may have migrated from /sse to /mcp - check the server's documentation"), @@ -692,13 +704,13 @@ func (mc *Client) onStateChange(oldState, newState types.ConnectionState, info * if mc.isNormalReconnectionError(info.LastError) { mc.logger.Warn("Connection error, will attempt automatic reconnection", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.String("error_type", "normal_reconnection"), zap.Error(info.LastError), zap.Int("retry_count", info.RetryCount)) } else { mc.logger.Error("Connection error", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Error(info.LastError), zap.Int("retry_count", info.RetryCount)) } @@ -721,7 +733,7 @@ func (mc *Client) stopBackgroundMonitoring() { // Only proceed if monitoring was actually started if !mc.monitoringStarted { mc.logger.Debug("Background monitoring was never started, skipping stop", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) return } @@ -737,10 +749,10 @@ func (mc *Client) stopBackgroundMonitoring() { select { case <-done: mc.logger.Debug("Background monitoring stopped successfully", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) case <-time.After(1 * time.Second): mc.logger.Warn("Background monitoring stop timed out after 1s, forcing shutdown", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) } mc.monitoringStarted = false @@ -760,7 +772,7 @@ func (mc *Client) backgroundHealthCheck() { mc.performHealthCheck() case <-mc.stopMonitoring: mc.logger.Debug("Background health monitoring stopped", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) return } } @@ -771,7 +783,7 @@ func (mc *Client) performHealthCheck() { // Skip all health/reconnect work when user explicitly logged out if mc.IsUserLoggedOut() { mc.logger.Debug("Health check skipped - user explicitly logged out", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) return } @@ -780,14 +792,14 @@ func (mc *Client) performHealthCheck() { if mc.StateManager.ShouldRetryOAuth() { info := mc.StateManager.GetConnectionInfo() mc.logger.Info("Attempting OAuth reconnection with extended backoff", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Int("oauth_retry_count", info.OAuthRetryCount), zap.Time("last_oauth_attempt", info.LastOAuthAttempt)) mc.tryReconnect() } else { info := mc.StateManager.GetConnectionInfo() mc.logger.Debug("OAuth backoff period not elapsed, skipping reconnection", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Int("oauth_retry_count", info.OAuthRetryCount), zap.Time("last_oauth_attempt", info.LastOAuthAttempt)) } @@ -801,14 +813,14 @@ func (mc *Client) performHealthCheck() { // Log once at WARN then suppress — server needs manual reconnect if info.RetryCount == types.MaxConnectionRetries { mc.logger.Warn("Giving up automatic reconnection after max retries — use manual reconnect or reconnect-on-use", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Int("retry_count", info.RetryCount)) } return } if mc.ShouldRetry() { mc.logger.Info("Attempting automatic reconnection with exponential backoff", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Int("retry_count", info.RetryCount)) mc.tryReconnect() @@ -824,8 +836,8 @@ func (mc *Client) performHealthCheck() { // Skip health checks for Docker servers to avoid interference with container management if mc.isDockerServer() { mc.logger.Debug("Skipping health check for Docker server", - zap.String("server", mc.Config.Name), - zap.String("command", mc.Config.Command)) + zap.String("server", mc.GetConfig().Name), + zap.String("command", mc.GetConfig().Command)) return } @@ -836,7 +848,7 @@ func (mc *Client) performHealthCheck() { listCtx, release, ok := mc.acquireListToolsContext(ctx, 5*time.Second) if !ok { mc.logger.Debug("Health check skipped - ListTools already in progress", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) return } @@ -852,20 +864,20 @@ func (mc *Client) performHealthCheck() { if mc.isConnectionError(err) { if mc.recordHealthCheckFailure(err) { mc.logger.Warn("Health check failed repeatedly, marking as error", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Int("consecutive_failures", mc.consecutiveHealthFailures), zap.Error(err)) mc.StateManager.SetError(err) } else { mc.logger.Info("Health check failed transiently, tolerating below threshold", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Int("consecutive_failures", mc.consecutiveHealthFailures), zap.Int("threshold", healthCheckFailureThreshold), zap.Error(err)) } } else { mc.logger.Debug("Health check failed with timeout (high activity), ignoring", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Error(err)) } return @@ -873,7 +885,7 @@ func (mc *Client) performHealthCheck() { mc.recordHealthCheckSuccess() mc.logger.Debug("Health check passed successfully", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) } // recordHealthCheckFailure increments the consecutive-failure counter and @@ -957,14 +969,14 @@ func (mc *Client) ForceReconnect(reason string) { if mc.IsUserLoggedOut() { mc.logger.Info("Force reconnect skipped - user explicitly logged out", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.String("reason", reason)) return } serverName := "" - if mc.Config != nil { - serverName = mc.Config.Name + if mc.GetConfig() != nil { + serverName = mc.GetConfig().Name } if mc.IsConnected() { @@ -995,7 +1007,7 @@ func (mc *Client) ForceReconnect(reason string) { func (mc *Client) tryReconnect() { if mc.IsUserLoggedOut() { mc.logger.Info("Skipping reconnection attempt - user explicitly logged out", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) return } @@ -1004,7 +1016,7 @@ func (mc *Client) tryReconnect() { if mc.reconnectInProgress { mc.reconnectMu.Unlock() mc.logger.Debug("Reconnection already in progress, skipping duplicate attempt", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) return } mc.reconnectInProgress = true @@ -1022,7 +1034,7 @@ func (mc *Client) tryReconnect() { defer cancel() mc.logger.Info("Starting reconnection attempt", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.String("current_state", mc.StateManager.GetState().String())) // First, disconnect the current client to clean up any broken connections @@ -1031,7 +1043,7 @@ func (mc *Client) tryReconnect() { mc.cancelInFlightListTools() if err := mc.coreClient.Disconnect(); err != nil { mc.logger.Warn("Failed to disconnect during reconnection attempt", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Error(err)) } @@ -1046,19 +1058,19 @@ func (mc *Client) tryReconnect() { // Use different log levels based on error type and retry count if mc.isOAuthError(err) { mc.logger.Warn("OAuth reconnection attempt failed, extended backoff will apply", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.String("error_type", "oauth_authentication"), zap.Error(err), zap.Int("oauth_retry_count", info.OAuthRetryCount)) } else if mc.isNormalReconnectionError(err) && info.RetryCount <= 5 { mc.logger.Warn("Reconnection attempt failed, will retry with exponential backoff", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.String("error_type", "normal_reconnection"), zap.Error(err), zap.Int("retry_count", info.RetryCount)) } else { mc.logger.Error("Reconnection attempt failed", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Error(err), zap.Int("retry_count", info.RetryCount)) } @@ -1067,7 +1079,7 @@ func (mc *Client) tryReconnect() { } mc.logger.Info("Reconnection attempt successful", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.String("new_state", mc.StateManager.GetState().String())) } @@ -1121,8 +1133,8 @@ func (mc *Client) TryReconnectSync(ctx context.Context) error { }() serverName := "" - if mc.Config != nil { - serverName = mc.Config.Name + if mc.GetConfig() != nil { + serverName = mc.GetConfig().Name } mc.logger.Info("TryReconnectSync: starting synchronous reconnect", @@ -1261,7 +1273,7 @@ func (mc *Client) isTokenRefreshScenario(err error) bool { for _, indicator := range tokenRefreshIndicators { if containsString(errStr, indicator) { mc.logger.Debug("🔄 Detected token refresh scenario", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.String("indicator", indicator)) return true } @@ -1378,7 +1390,7 @@ func (mc *Client) GetCachedToolCount(ctx context.Context) (int, error) { // Cache miss or expired - need to fetch fresh count if !mc.IsConnected() { mc.logger.Debug("🔍 Tool count fetch skipped - client not connected", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.String("state", mc.StateManager.GetState().String())) return 0, fmt.Errorf("client not connected (state: %s)", mc.StateManager.GetState().String()) } @@ -1386,14 +1398,14 @@ func (mc *Client) GetCachedToolCount(ctx context.Context) (int, error) { listCtx, release, ok := mc.acquireListToolsContext(ctx, 30*time.Second) if !ok { mc.logger.Debug("🔍 Tool count fetch skipped - ListTools already in progress", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) // Return cached count even if expired rather than causing another concurrent call return cachedCount, nil } defer release() mc.logger.Debug("🔍 Tool count cache miss - fetching fresh count", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Bool("cache_expired", !cachedTime.IsZero()), zap.Duration("cache_age", time.Since(cachedTime))) @@ -1403,7 +1415,7 @@ func (mc *Client) GetCachedToolCount(ctx context.Context) (int, error) { mc.publishListToolsResult(tools, err) if err != nil { mc.logger.Debug("Tool count fetch failed, returning cached value", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Error(err), zap.Int("cached_count", cachedCount)) @@ -1425,7 +1437,7 @@ func (mc *Client) GetCachedToolCount(ctx context.Context) (int, error) { mc.setToolCountCache(freshCount) mc.logger.Debug("🔍 Tool count cache updated", - zap.String("server", mc.Config.Name), + zap.String("server", mc.GetConfig().Name), zap.Int("fresh_count", freshCount), zap.Int("previous_count", cachedCount)) @@ -1450,7 +1462,7 @@ func (mc *Client) InvalidateToolCountCache() { mc.toolCountMu.Unlock() mc.logger.Debug("🔍 Tool count cache invalidated", - zap.String("server", mc.Config.Name)) + zap.String("server", mc.GetConfig().Name)) } // Helper function to check if string contains substring @@ -1493,5 +1505,5 @@ func (mc *Client) setToolCountCache(count int) { // isDockerServer checks if the server is running via Docker func (mc *Client) isDockerServer() bool { - return containsString(mc.Config.Command, "docker") + return containsString(mc.GetConfig().Command, "docker") } diff --git a/internal/upstream/managed/health_flap_test.go b/internal/upstream/managed/health_flap_test.go index b2b9dcd2..d80cc1bb 100644 --- a/internal/upstream/managed/health_flap_test.go +++ b/internal/upstream/managed/health_flap_test.go @@ -16,9 +16,9 @@ import ( func newTestClientForHealth(t *testing.T) *Client { t.Helper() mc := &Client{ - Config: &config.ServerConfig{Name: "flap-server"}, logger: zap.NewNop(), } + mc.SetConfig(&config.ServerConfig{Name: "flap-server"}) mc.StateManager = types.NewStateManager() mc.StateManager.TransitionTo(types.StateConnecting) mc.StateManager.TransitionTo(types.StateReady) diff --git a/internal/upstream/managed/listtools_coalescing_test.go b/internal/upstream/managed/listtools_coalescing_test.go index 427d1138..da15435c 100644 --- a/internal/upstream/managed/listtools_coalescing_test.go +++ b/internal/upstream/managed/listtools_coalescing_test.go @@ -18,9 +18,9 @@ import ( func newTestReadyClient(t *testing.T) *Client { t.Helper() mc := &Client{ - Config: &config.ServerConfig{Name: "test-server"}, logger: zap.NewNop(), } + mc.SetConfig(&config.ServerConfig{Name: "test-server"}) mc.StateManager = types.NewStateManager() mc.StateManager.TransitionTo(types.StateConnecting) mc.StateManager.TransitionTo(types.StateReady) diff --git a/internal/upstream/manager.go b/internal/upstream/manager.go index 7d1bd3d4..acc4c4f2 100644 --- a/internal/upstream/manager.go +++ b/internal/upstream/manager.go @@ -273,7 +273,7 @@ func (m *Manager) AddServerConfig(id string, serverConfig *config.ServerConfig) // Check if existing client exists and if config has changed var clientToDisconnect *managed.Client if existingClient, exists := m.clients[id]; exists { - existingConfig := existingClient.Config + existingConfig := existingClient.GetConfig() // Compare configurations to determine if reconnection is needed configChanged := existingConfig.URL != serverConfig.URL || @@ -823,9 +823,9 @@ func (m *Manager) DiscoverTools(ctx context.Context) ([]*config.ToolMetadata, er name := "" quarantined := false enabled := false - // Read config through the thread-safe accessor (mc.mu) — the reconcile - // add path (AddServerConfig) calls SetConfig while holding only mc.mu, - // not m.mu, so a direct client.Config field read races with it (MCP-770). + // Read config through the thread-safe GetConfig() accessor — the reconcile + // add path (AddServerConfig) calls SetConfig (an atomic swap) off m.mu, so + // a direct config-field read would race with it (MCP-770). if client != nil { if cfg := client.GetConfig(); cfg != nil { name = cfg.Name @@ -923,7 +923,7 @@ func (m *Manager) CallTool(ctx context.Context, toolName string, args map[string // Find the client for this server var targetClient *managed.Client for _, client := range m.clients { - if client.Config.Name == serverName { + if client.GetConfig().Name == serverName { targetClient = client break } @@ -937,11 +937,11 @@ func (m *Manager) CallTool(ctx context.Context, toolName string, args map[string m.logger.Debug("CallTool: client found", zap.String("server_name", serverName), - zap.Bool("enabled", targetClient.Config.Enabled), + zap.Bool("enabled", targetClient.GetConfig().Enabled), zap.Bool("connected", targetClient.IsConnected()), zap.String("state", targetClient.GetState().String())) - if !targetClient.Config.Enabled { + if !targetClient.GetConfig().Enabled { return nil, fmt.Errorf("client for server %s is disabled", serverName) } @@ -954,9 +954,9 @@ func (m *Manager) CallTool(ctx context.Context, toolName string, args map[string // Attempt reconnect-on-use if enabled for this server reconnected := false - if targetClient.Config.ReconnectOnUse && + if targetClient.GetConfig().ReconnectOnUse && !targetClient.IsUserLoggedOut() && - !targetClient.Config.Quarantined { + !targetClient.GetConfig().Quarantined { m.logger.Info("reconnect_on_use: attempting reconnect for tool call", zap.String("server", serverName), zap.String("tool", actualToolName), @@ -1081,29 +1081,29 @@ func (m *Manager) ConnectAll(ctx context.Context) error { for id, client := range clients { m.logger.Debug("Evaluating client for connection", zap.String("id", id), - zap.String("name", client.Config.Name), - zap.Bool("enabled", client.Config.Enabled), + zap.String("name", client.GetConfig().Name), + zap.Bool("enabled", client.GetConfig().Enabled), zap.Bool("is_connected", client.IsConnected()), zap.Bool("is_connecting", client.IsConnecting()), zap.String("current_state", client.GetState().String()), - zap.Bool("quarantined", client.Config.Quarantined)) + zap.Bool("quarantined", client.GetConfig().Quarantined)) - if !client.Config.Enabled { + if !client.GetConfig().Enabled { m.logger.Debug("Skipping disabled client", zap.String("id", id), - zap.String("name", client.Config.Name)) + zap.String("name", client.GetConfig().Name)) if client.IsConnected() { - m.logger.Info("Disconnecting disabled client", zap.String("id", id), zap.String("name", client.Config.Name)) + m.logger.Info("Disconnecting disabled client", zap.String("id", id), zap.String("name", client.GetConfig().Name)) _ = client.Disconnect() } continue } - if client.Config.Quarantined { + if client.GetConfig().Quarantined { m.logger.Info("Skipping quarantined client", zap.String("id", id), - zap.String("name", client.Config.Name)) + zap.String("name", client.GetConfig().Name)) continue } @@ -1111,7 +1111,7 @@ func (m *Manager) ConnectAll(ctx context.Context) error { if client.IsUserLoggedOut() { m.logger.Debug("Skipping client - user explicitly logged out, waiting for manual login", zap.String("id", id), - zap.String("name", client.Config.Name)) + zap.String("name", client.GetConfig().Name)) continue } @@ -1119,14 +1119,14 @@ func (m *Manager) ConnectAll(ctx context.Context) error { if client.IsConnected() { m.logger.Debug("Client already connected, skipping", zap.String("id", id), - zap.String("name", client.Config.Name)) + zap.String("name", client.GetConfig().Name)) continue } if client.IsConnecting() { m.logger.Debug("Client already connecting, skipping", zap.String("id", id), - zap.String("name", client.Config.Name)) + zap.String("name", client.GetConfig().Name)) continue } @@ -1134,7 +1134,7 @@ func (m *Manager) ConnectAll(ctx context.Context) error { info := client.GetConnectionInfo() m.logger.Debug("Client backoff active, skipping connect attempt", zap.String("id", id), - zap.String("name", client.Config.Name), + zap.String("name", client.GetConfig().Name), zap.Int("retry_count", info.RetryCount), zap.Time("last_retry_time", info.LastRetryTime)) continue @@ -1142,10 +1142,10 @@ func (m *Manager) ConnectAll(ctx context.Context) error { m.logger.Info("Attempting to connect client", zap.String("id", id), - zap.String("name", client.Config.Name), - zap.String("url", client.Config.URL), - zap.String("command", client.Config.Command), - zap.String("protocol", client.Config.Protocol)) + zap.String("name", client.GetConfig().Name), + zap.String("url", client.GetConfig().URL), + zap.String("command", client.GetConfig().Command), + zap.String("protocol", client.GetConfig().Protocol)) wg.Add(1) go func(id string, c *managed.Client) { @@ -1162,13 +1162,13 @@ func (m *Manager) ConnectAll(ctx context.Context) error { if err := c.Connect(connectCtx); err != nil { m.logger.Error("Failed to connect to upstream server", zap.String("id", id), - zap.String("name", c.Config.Name), + zap.String("name", c.GetConfig().Name), zap.String("state", c.GetState().String()), zap.Error(err)) } else { m.logger.Info("Successfully initiated connection to upstream server", zap.String("id", id), - zap.String("name", c.Config.Name)) + zap.String("name", c.GetConfig().Name)) } }(id, client) } @@ -1473,7 +1473,7 @@ func (m *Manager) RetryConnection(serverName string) error { var hasToken bool var tokenExpires time.Time if m.storage != nil { - ts := oauth.NewPersistentTokenStore(client.Config.Name, client.Config.URL, m.storage) + ts := oauth.NewPersistentTokenStore(client.GetConfig().Name, client.GetConfig().URL, m.storage) if tok, err := ts.GetToken(context.Background()); err == nil && tok != nil { hasToken = true tokenExpires = tok.ExpiresAt @@ -1842,7 +1842,7 @@ func (m *Manager) StartManualOAuth(serverName string, force bool) error { return fmt.Errorf("server not found: %s", serverName) } - cfg := client.Config + cfg := client.GetConfig() m.logger.Info("Starting in-process manual OAuth", zap.String("server", cfg.Name), zap.Bool("force", force)) @@ -1925,7 +1925,7 @@ func (m *Manager) StartManualOAuthQuick(serverName string) (*core.OAuthStartResu return nil, fmt.Errorf("server not found: %s", serverName) } - cfg := client.Config + cfg := client.GetConfig() m.logger.Info("Starting quick OAuth flow (returns browser status immediately)", zap.String("server", cfg.Name)) @@ -2008,7 +2008,7 @@ func (m *Manager) StartManualOAuthWithInfo(serverName string, force bool) (*core return nil, fmt.Errorf("server not found: %s", serverName) } - cfg := client.Config + cfg := client.GetConfig() m.logger.Info("Starting in-process manual OAuth with info tracking", zap.String("server", cfg.Name), zap.Bool("force", force)) diff --git a/internal/upstream/manager_config_race_test.go b/internal/upstream/manager_config_race_test.go index 73361642..85fc289f 100644 --- a/internal/upstream/manager_config_race_test.go +++ b/internal/upstream/manager_config_race_test.go @@ -61,3 +61,50 @@ func TestDiscoverTools_ConfigRace(t *testing.T) { wg.Wait() } + +// TestConnect_ConfigRace reproduces the sibling MCP-770 race surfaced on PR #555 +// (macOS -race unit job): reconcile spawns AddServer (-> SetConfig writes the +// mc.Config pointer under mc.mu) and ConnectServer (-> Client.Connect) as +// concurrent goroutines. Connect releases mc.mu before the slow core connect and +// logged the server name by dereferencing mc.Config in that unlocked window, +// racing SetConfig's write. The fix snapshots the name under the Phase-1 lock. +// Run under `go test -race`. +func TestConnect_ConfigRace(t *testing.T) { + serverConfig := &config.ServerConfig{ + Name: "race-server", + URL: "http://127.0.0.1:0", // unreachable -> core Connect fails fast + Protocol: "http", + Enabled: true, + Created: time.Now(), + } + + manager, client := createTestManagerWithClient(t, serverConfig) + + const iterations = 200 + var wg sync.WaitGroup + wg.Add(2) + + // Writer: reconcile add path -> SetConfig swaps the mc.Config pointer. + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + cfg := *serverConfig + cfg.Created = time.Now() + _ = manager.AddServerConfig(serverConfig.Name, &cfg) + } + }() + + // Reader: reconcile connect path -> Client.Connect reads the config in its + // unlocked phase. The failing core connect leaves the client in Error state, + // so each iteration passes the connecting/ready guard and reaches the read. + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + _ = client.Connect(ctx) + cancel() + } + }() + + wg.Wait() +} From ddebd244ee568961cb64538a98bc4de5b73d7c91 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Mon, 1 Jun 2026 07:17:43 +0300 Subject: [PATCH 19/23] test(070): close mainServer storage so Windows temp-dir cleanup succeeds newAddFromRegistryTestServer opened a real Server (and its BBolt config.db under t.TempDir()) but never closed it. On Windows an open file cannot be unlinked, so t.TempDir's RemoveAll failed the AddFromRegistry tests with "config.db ... being used by another process". Unix unlinks open files, so ubuntu/macOS passed and hid it; the failure only surfaced once the MCP-770 race fix stopped aborting the test binary before cleanup ran. Register t.Cleanup(mainSrv.Shutdown) after t.TempDir() so LIFO cleanup closes the runtime/storage (releasing the handle) before the temp dir is removed. Shutdown is nil-httpServer-safe and idempotent. Related: MCP-770, spec 070 --- internal/server/mcp_add_from_registry_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/server/mcp_add_from_registry_test.go b/internal/server/mcp_add_from_registry_test.go index 9c119dec..eb5bf140 100644 --- a/internal/server/mcp_add_from_registry_test.go +++ b/internal/server/mcp_add_from_registry_test.go @@ -33,6 +33,12 @@ func newAddFromRegistryTestServer(t *testing.T) *MCPProxyServer { mainSrv, err := NewServer(cfg, logger) require.NoError(t, err) + // Close the runtime/BBolt storage when the test ends so the config.db handle + // is released before t.TempDir's RemoveAll runs. On Windows an open file + // cannot be unlinked, so without this the temp-dir cleanup fails the test + // ("config.db ... being used by another process"). Registered after + // t.TempDir() (line above) so LIFO cleanup closes the DB first. + t.Cleanup(func() { _ = mainSrv.Shutdown() }) proxy.mainServer = mainSrv return proxy From e2d0e56f25e675ec77e2484abdf3de1aa2d4184e Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Mon, 1 Jun 2026 07:32:27 +0300 Subject: [PATCH 20/23] fix(core): serialize stderr/process monitoring lifecycle (MCP-770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StartStderrMonitoring (from connectStdio during a reconcile-driven Connect) and StopStderrMonitoring (from Disconnect during Manager.ShutdownAll) accessed the monitoring lifecycle fields — stderrMonitoringCtx/Cancel/WaitGroup and the process-monitoring equivalents — with no synchronization. When a connect and a shutdown overlap on the same client, the -race detector flags WG.Add (Start) vs WG.Wait (Stop). CI caught this on PR #555's ubuntu unit job once the earlier MCP-770 fixes (atomic Config) + the Windows temp-dir cleanup made the test's shutdown path actually run concurrently with reconcile. Add a dedicated monitoringMu that makes the four Start*/Stop*Monitoring methods mutually exclusive, so Add never overlaps Wait. The mutex is a leaf (monitor goroutines never acquire it; it is never held across c.mu), so Stop's bounded WG.Wait under the lock cannot deadlock. Add TestStderrMonitoring_StartStopRace: hammers Start/Stop concurrently; trips -race on the unsynchronized fields, green with monitoringMu. Related: MCP-770, spec 070 --- internal/upstream/core/client.go | 8 ++++ internal/upstream/core/monitoring.go | 12 +++++ .../upstream/core/monitoring_race_test.go | 48 +++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 internal/upstream/core/monitoring_race_test.go diff --git a/internal/upstream/core/client.go b/internal/upstream/core/client.go index 375380bc..4f3111a1 100644 --- a/internal/upstream/core/client.go +++ b/internal/upstream/core/client.go @@ -75,6 +75,14 @@ type Client struct { // Cached tools list from successful immediate call cachedTools []mcp.Tool + // monitoringMu serializes the stderr/process monitoring lifecycle methods + // (Start*/Stop*Monitoring). Connect (StartStderrMonitoring) and Disconnect + // (StopStderrMonitoring) can run concurrently on the same client during a + // reconcile-vs-shutdown overlap, racing the ctx/cancel/WaitGroup fields + // below (notably WG.Add vs WG.Wait). This mutex makes start and stop + // mutually exclusive. It is never held across c.mu. + monitoringMu sync.Mutex + // Stderr monitoring stderrMonitoringCtx context.Context stderrMonitoringCancel context.CancelFunc diff --git a/internal/upstream/core/monitoring.go b/internal/upstream/core/monitoring.go index 92be5fe3..3b3588be 100644 --- a/internal/upstream/core/monitoring.go +++ b/internal/upstream/core/monitoring.go @@ -24,6 +24,9 @@ const ( // StartStderrMonitoring starts monitoring stderr output and logging it func (c *Client) StartStderrMonitoring() { + c.monitoringMu.Lock() + defer c.monitoringMu.Unlock() + if c.stderr == nil || c.transportType != transportStdio { return } @@ -43,6 +46,9 @@ func (c *Client) StartStderrMonitoring() { // StopStderrMonitoring stops stderr monitoring func (c *Client) StopStderrMonitoring() { + c.monitoringMu.Lock() + defer c.monitoringMu.Unlock() + if c.stderrMonitoringCancel != nil { c.stderrMonitoringCancel() @@ -66,6 +72,9 @@ func (c *Client) StopStderrMonitoring() { // StartProcessMonitoring starts monitoring the underlying process func (c *Client) StartProcessMonitoring() { + c.monitoringMu.Lock() + defer c.monitoringMu.Unlock() + // Start monitoring even if processCmd is nil for Docker containers if c.processCmd == nil && !c.isDockerCommand { return @@ -94,6 +103,9 @@ func (c *Client) StartProcessMonitoring() { // StopProcessMonitoring stops process monitoring func (c *Client) StopProcessMonitoring() { + c.monitoringMu.Lock() + defer c.monitoringMu.Unlock() + if c.processMonitorCancel != nil { c.processMonitorCancel() diff --git a/internal/upstream/core/monitoring_race_test.go b/internal/upstream/core/monitoring_race_test.go new file mode 100644 index 00000000..eb4c76b1 --- /dev/null +++ b/internal/upstream/core/monitoring_race_test.go @@ -0,0 +1,48 @@ +package core + +import ( + "strings" + "sync" + "testing" + + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// TestStderrMonitoring_StartStopRace reproduces the Connect-vs-Disconnect race +// on the stderr-monitoring lifecycle fields (stderrMonitoringCtx/Cancel/WG). +// StartStderrMonitoring runs from connectStdio during a reconcile-driven Connect +// while StopStderrMonitoring runs from Disconnect during Manager.ShutdownAll, with +// no synchronization on those fields — the -race detector flags WG.Add (Start) +// vs WG.Wait (Stop). Run under `go test -race`: trips without monitoringMu, green +// with it. A reused empty stderr reader returns EOF immediately so monitorStderr +// exits at once and the loop stays fast. +func TestStderrMonitoring_StartStopRace(t *testing.T) { + c := &Client{ + transportType: transportStdio, + stderr: strings.NewReader(""), + logger: zap.NewNop(), + config: &config.ServerConfig{Name: "race"}, + } + + const iterations = 500 + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + c.StartStderrMonitoring() + } + }() + go func() { + defer wg.Done() + for i := 0; i < iterations; i++ { + c.StopStderrMonitoring() + } + }() + + wg.Wait() + c.StopStderrMonitoring() +} From e2e54d1260ad871f305ab43980a12b559671ec6c Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Mon, 1 Jun 2026 08:00:02 +0300 Subject: [PATCH 21/23] fix(core): per-cycle done channel for monitoring stop (MCP-770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The monitoringMu serialization (prev commit) was incomplete: StopStderr/ StopProcessMonitoring ran WG.Wait() in a DETACHED goroutine guarded by a 500ms timeout. When a real child keeps its stderr open, monitorStderr blocks in scanner.Scan(), the wait times out, Stop returns and releases monitoringMu — but the detached WG.Wait goroutine lives on and races the next connect's WG.Add. CI caught this on #555 (DiscoverTools→Connect StartStderrMonitoring WG.Add vs the lingering StopStderrMonitoring WG.Wait). Replace the reused sync.WaitGroup with a per-cycle `done chan struct{}` the monitor goroutine closes, and pass the context + done as locals so an abandoned (timed-out) monitor never reads the shared ctx/state a later Start overwrites. Stop now waits on done directly under monitoringMu (no detached waiter) and nils the per-cycle fields. monitorStderr/monitorProcess take ctx as a parameter. Strengthen TestStderrMonitoring with an AbandonedMonitorNoRace case: a blocking pipe keeps the monitor alive so Stop hits the timeout/abandon path — the exact escape the WaitGroup version raced on. AddFromRegistry passes 12x under -race. Related: MCP-770, spec 070 --- internal/upstream/core/client.go | 9 +- internal/upstream/core/monitoring.go | 105 ++++++++++-------- .../upstream/core/monitoring_race_test.go | 38 +++++++ 3 files changed, 103 insertions(+), 49 deletions(-) diff --git a/internal/upstream/core/client.go b/internal/upstream/core/client.go index 4f3111a1..724f87ee 100644 --- a/internal/upstream/core/client.go +++ b/internal/upstream/core/client.go @@ -83,10 +83,13 @@ type Client struct { // mutually exclusive. It is never held across c.mu. monitoringMu sync.Mutex - // Stderr monitoring + // Stderr monitoring. stderrMonitoringDone is a per-cycle channel closed by + // the monitor goroutine when it exits; Stop waits on it instead of a reused + // sync.WaitGroup, so an abandoned (timed-out) wait never races a later + // Start's counter. All three fields are written only under monitoringMu. stderrMonitoringCtx context.Context stderrMonitoringCancel context.CancelFunc - stderrMonitoringWG sync.WaitGroup + stderrMonitoringDone chan struct{} // Ring buffer of recent stderr lines from the subprocess. // Populated by monitorStderr; surfaced in initialize failure messages so @@ -100,7 +103,7 @@ type Client struct { processGroupID int // Process group ID for proper cleanup processMonitorCtx context.Context processMonitorCancel context.CancelFunc - processMonitorWG sync.WaitGroup + processMonitorDone chan struct{} // Docker container tracking containerID string diff --git a/internal/upstream/core/monitoring.go b/internal/upstream/core/monitoring.go index 3b3588be..809a684d 100644 --- a/internal/upstream/core/monitoring.go +++ b/internal/upstream/core/monitoring.go @@ -31,13 +31,17 @@ func (c *Client) StartStderrMonitoring() { return } - // Create context for stderr monitoring - c.stderrMonitoringCtx, c.stderrMonitoringCancel = context.WithCancel(context.Background()) + // Create context for stderr monitoring. The monitor goroutine receives the + // context and its done channel as locals so an abandoned (timed-out) + // goroutine never reads the shared fields a later Start may overwrite. + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + c.stderrMonitoringCtx, c.stderrMonitoringCancel = ctx, cancel + c.stderrMonitoringDone = done - c.stderrMonitoringWG.Add(1) go func() { - defer c.stderrMonitoringWG.Done() - c.monitorStderr() + defer close(done) + c.monitorStderr(ctx) }() c.logger.Debug("Started stderr monitoring", @@ -49,24 +53,29 @@ func (c *Client) StopStderrMonitoring() { c.monitoringMu.Lock() defer c.monitoringMu.Unlock() - if c.stderrMonitoringCancel != nil { - c.stderrMonitoringCancel() + if c.stderrMonitoringCancel == nil { + return + } - // Use a timeout for the wait to prevent hanging - done := make(chan struct{}) - go func() { - c.stderrMonitoringWG.Wait() - close(done) - }() + c.stderrMonitoringCancel() + done := c.stderrMonitoringDone + c.stderrMonitoringCancel = nil + c.stderrMonitoringDone = nil + if done == nil { + return + } - select { - case <-done: - c.logger.Debug("Stopped stderr monitoring", - zap.String("server", c.config.Name)) - case <-time.After(500 * time.Millisecond): - c.logger.Warn("Stderr monitoring stop timed out after 500ms, forcing shutdown", - zap.String("server", c.config.Name)) - } + // Wait for the monitor goroutine directly under monitoringMu (no detached + // waiter that could outlive the lock). On timeout the goroutine is abandoned; + // it closes its own done channel and touches only its captured ctx, so it + // cannot race a subsequent Start. + select { + case <-done: + c.logger.Debug("Stopped stderr monitoring", + zap.String("server", c.config.Name)) + case <-time.After(500 * time.Millisecond): + c.logger.Warn("Stderr monitoring stop timed out after 500ms, forcing shutdown", + zap.String("server", c.config.Name)) } } @@ -80,13 +89,16 @@ func (c *Client) StartProcessMonitoring() { return } - // Create context for process monitoring - c.processMonitorCtx, c.processMonitorCancel = context.WithCancel(context.Background()) + // Create context for process monitoring (ctx + done passed as locals; see + // StartStderrMonitoring for the abandoned-goroutine rationale). + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + c.processMonitorCtx, c.processMonitorCancel = ctx, cancel + c.processMonitorDone = done - c.processMonitorWG.Add(1) go func() { - defer c.processMonitorWG.Done() - c.monitorProcess() + defer close(done) + c.monitorProcess(ctx) }() if c.processCmd != nil { @@ -106,29 +118,30 @@ func (c *Client) StopProcessMonitoring() { c.monitoringMu.Lock() defer c.monitoringMu.Unlock() - if c.processMonitorCancel != nil { - c.processMonitorCancel() + if c.processMonitorCancel == nil { + return + } - // Use a timeout for the wait to prevent hanging - done := make(chan struct{}) - go func() { - c.processMonitorWG.Wait() - close(done) - }() + c.processMonitorCancel() + done := c.processMonitorDone + c.processMonitorCancel = nil + c.processMonitorDone = nil + if done == nil { + return + } - select { - case <-done: - c.logger.Debug("Stopped process monitoring", - zap.String("server", c.config.Name)) - case <-time.After(500 * time.Millisecond): - c.logger.Warn("Process monitoring stop timed out after 500ms, forcing shutdown", - zap.String("server", c.config.Name)) - } + select { + case <-done: + c.logger.Debug("Stopped process monitoring", + zap.String("server", c.config.Name)) + case <-time.After(500 * time.Millisecond): + c.logger.Warn("Process monitoring stop timed out after 500ms, forcing shutdown", + zap.String("server", c.config.Name)) } } // monitorProcess monitors the underlying process health -func (c *Client) monitorProcess() { +func (c *Client) monitorProcess(ctx context.Context) { // Only return early if we have neither processCmd nor Docker command if c.processCmd == nil && !c.isDockerCommand { return @@ -142,7 +155,7 @@ func (c *Client) monitorProcess() { for { select { - case <-c.processMonitorCtx.Done(): + case <-ctx.Done(): return case <-ticker.C: if isDocker { @@ -153,11 +166,11 @@ func (c *Client) monitorProcess() { } // monitorStderr monitors stderr output and logs it to both main and server-specific logs -func (c *Client) monitorStderr() { +func (c *Client) monitorStderr(ctx context.Context) { scanner := bufio.NewScanner(c.stderr) for scanner.Scan() { select { - case <-c.stderrMonitoringCtx.Done(): + case <-ctx.Done(): return default: line := strings.TrimSpace(scanner.Text()) diff --git a/internal/upstream/core/monitoring_race_test.go b/internal/upstream/core/monitoring_race_test.go index eb4c76b1..6ab87c5a 100644 --- a/internal/upstream/core/monitoring_race_test.go +++ b/internal/upstream/core/monitoring_race_test.go @@ -1,6 +1,7 @@ package core import ( + "io" "strings" "sync" "testing" @@ -46,3 +47,40 @@ func TestStderrMonitoring_StartStopRace(t *testing.T) { wg.Wait() c.StopStderrMonitoring() } + +// TestStderrMonitoring_AbandonedMonitorNoRace models the round-5 escape: the +// monitor goroutine is still alive when Stop is called (its stderr Read blocks), +// so Stop hits the 500ms timeout and abandons it. With the old reused-WaitGroup +// design the abandoned WG.Wait raced the next cycle's WG.Add; the per-cycle done +// channel + ctx-as-param design must keep concurrent Start/Stop race-free even +// while a prior monitor lingers. A blocking pipe keeps monitorStderr alive; +// closing the writer on cleanup lets the leaked goroutines exit. +func TestStderrMonitoring_AbandonedMonitorNoRace(t *testing.T) { + pr, pw := io.Pipe() + t.Cleanup(func() { _ = pw.Close() }) + + c := &Client{ + transportType: transportStdio, + stderr: pr, // Read blocks until the writer is closed + logger: zap.NewNop(), + config: &config.ServerConfig{Name: "race"}, + } + + const cycles = 4 // each Stop times out at 500ms; keep small + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < cycles; i++ { + c.StartStderrMonitoring() + } + }() + go func() { + defer wg.Done() + for i := 0; i < cycles; i++ { + c.StopStderrMonitoring() + } + }() + wg.Wait() + c.StopStderrMonitoring() +} From 98af02c50ef66ac3b2e8f8d94fd1fa6e31e15aea Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Mon, 1 Jun 2026 08:49:37 +0300 Subject: [PATCH 22/23] docs(070): document registry add/refresh surfaces (MCP-788, ENG-9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kimi's dual-AI review of #555 returned REQUEST_CHANGES on the missing docs per ENG-9. Add docs for the new registry surfaces: - docs/features/registry-add.md — cohesive feature guide covering all three surfaces (CLI `registry list/search/add`, REST add/refresh/browse endpoints, MCP `upstream_servers` add_from_registry op), the CN-001 re-derivation security model, quarantine-by-default, and the cross-surface missing_required_input error contract. - docs/api/rest-api.md — new Registries section listing the four endpoints (list, search, add, refresh) with request/response shapes, linking the feature guide. Docs only; no code change. Clears Kimi's REQUEST_CHANGES for the Gate-3 merge. Related: MCP-788, MCP-785, MCP-761, spec 070 --- docs/api/rest-api.md | 34 +++++++ docs/features/registry-add.md | 168 ++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 docs/features/registry-add.md diff --git a/docs/api/rest-api.md b/docs/api/rest-api.md index 48bae17b..e64fbf97 100644 --- a/docs/api/rest-api.md +++ b/docs/api/rest-api.md @@ -534,6 +534,40 @@ sets `partial: true` with `failed_servers` (it does not fail the whole request). List tools for a specific server. +### Registries + +Discover MCP servers in known registries and add them as quarantined upstreams. +The daemon re-derives the runnable config server-side — the client never sends a +config blob. See [Adding servers from registries](../features/registry-add.md) +for the full feature guide (CLI, REST, MCP). + +#### GET /api/v1/registries + +List configured registries. + +#### GET /api/v1/registries/{id}/servers + +Search a registry's servers (`?search=`, `?tag=`, `?limit=`). + +#### POST /api/v1/registries/{id}/servers/{serverId}/add + +Add a server from a registry as a quarantined upstream. Optional JSON body +carries only overrides (never a config blob): + +```json +{ "name": "github-mcp", "env": { "GITHUB_TOKEN": "…" }, "enabled": true } +``` + +Success returns `data.server` (`name`, `protocol`, `command`, `args`, `url`, +`enabled`, `quarantined`). A missing required input returns +`{"success": false, "code": "missing_required_input", "missing_inputs": [...]}` +— the same cross-surface code emitted by the CLI and MCP surfaces. + +#### POST /api/v1/registries/{id}/refresh + +Drop a registry's cached server lists. Returns +`{ "registry_id": "...", "cleared": }`. + ### Real-time Updates #### GET /events diff --git a/docs/features/registry-add.md b/docs/features/registry-add.md new file mode 100644 index 00000000..848a9203 --- /dev/null +++ b/docs/features/registry-add.md @@ -0,0 +1,168 @@ +# Adding Servers from Registries + +MCPProxy can discover MCP servers in known registries and add them as upstream +servers without you hand-constructing a `command`/`args`/`url`. You reference a +server by `(registryId, serverId)`; the daemon re-derives the runnable config +from the registry entry and adds it **quarantined** so you can review it before +it is exposed to agents. + +The same operation is available from the CLI, the REST API, and the MCP +`upstream_servers` tool. All three surfaces call one server-side core operation +(`AddServerFromRegistry`), so behaviour and error codes are identical everywhere +(spec 070, CN-001). + +## Security model + +- **The client never sends a config blob.** Only the registry reference plus + optional overrides (`name`, `env`, `enabled`) cross the wire. The daemon + re-derives `command`/`args`/`url` from the registry entry, so a caller cannot + smuggle a different command or pre-approve the server (CN-001 / security + decision D1). +- **Quarantined by default.** A freshly added server is quarantined until you + approve it: + ```bash + mcpproxy upstream approve + ``` +- **Required inputs are explicit.** If a registry entry declares required inputs + (e.g. `${GITHUB_TOKEN}`) and you don't supply them, the add fails with the + stable code `missing_required_input` and the exact input names — no partially + configured server is persisted (FR-003). + +## Discovering servers + +Before adding, find the registry id and server id: + +```bash +mcpproxy registry list # list configured registries + ids +mcpproxy registry search -r # search one registry +mcpproxy registry search sqlite -r pulse --tag database --limit 5 +``` + +`registry search` flags: `--registry/-r `, `--tag/-t `, +`--limit/-l ` (default 10). + +## CLI: `mcpproxy registry add` + +```bash +mcpproxy registry add [flags] +``` + +Flags: + +| Flag | Description | +|------|-------------| +| `--name ` | Override the server name | +| `--env KEY=VALUE` | Set an environment variable / required input (repeatable) | +| `--enabled` | Whether the added server is enabled (default `true`) | + +Example: + +```bash +mcpproxy registry add pulse github-mcp --env GITHUB_TOKEN=ghp_xxx +# ✅ Added 'github-mcp' (quarantined — approve with: mcpproxy upstream approve github-mcp) +``` + +Notes: + +- `registry add` **requires a running daemon** — the keystone op is server-side. + If the daemon isn't running you get a `connection_failed` error telling you to + `mcpproxy serve`. +- Use `-o json` / `-o yaml` for machine-readable output of the added server. + +## REST API + +Base path: `/api/v1`. Authenticate with `X-API-Key` (see +[REST API](../api/rest-api.md)). + +### Add a server from a registry + +``` +POST /api/v1/registries/{id}/servers/{serverId}/add +``` + +The registry id and server id come from the URL path. The optional JSON body +carries only overrides — never a config blob: + +```json +{ + "name": "github-mcp", // optional name override + "env": { "GITHUB_TOKEN": "…" }, // overrides + required-input values + "enabled": true // optional, defaults to true +} +``` + +Success (`200`): + +```json +{ + "success": true, + "data": { + "server": { + "name": "github-mcp", + "protocol": "stdio", + "command": "npx", + "args": ["github-mcp"], + "enabled": true, + "quarantined": true + } + } +} +``` + +Failure carries the cross-surface error code: + +```json +{ + "success": false, + "code": "missing_required_input", + "message": "…", + "missing_inputs": ["GITHUB_TOKEN"] +} +``` + +`missing_inputs` is present only for `code == "missing_required_input"`. + +### Refresh a registry's cache + +Drop a registry's cached server lists so the next discovery re-fetches them +(FR-007): + +``` +POST /api/v1/registries/{id}/refresh +``` + +Response: + +```json +{ "registry_id": "pulse", "cleared": 3 } +``` + +`cleared` is the number of cached entries dropped. + +### Browsing endpoints + +- `GET /api/v1/registries` — list registries. +- `GET /api/v1/registries/{id}/servers` — search a registry's servers. + +## MCP tool + +Use the built-in `upstream_servers` tool with `operation: "add_from_registry"`: + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `registry` | yes | Registry id (e.g. `pulse`). Discover via the `list_registries` / `search_servers` tools | +| `id` | yes | Server id within the registry | +| `name` | no | Name override | +| `env_json` | no | JSON object of env / required-input values, e.g. `{"GITHUB_TOKEN":"…"}` | +| `enabled` | no | Defaults to `true`; pass `false` to add disabled | + +The server re-derives the runnable config and quarantines it. On a missing +required input the tool returns a structured error result with the same `code` +and `missing_inputs` as the REST and CLI surfaces (CN-001). + +## See also + +- [CLI management commands](../cli/management-commands.md) +- [Security & quarantine](security-quarantine.md) +- [Search & discovery](search-discovery.md) +- [REST API reference](../api/rest-api.md) From d24c577caa4b229f1f4dd2a1c00ffc73da16eb7b Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Mon, 1 Jun 2026 10:47:54 +0300 Subject: [PATCH 23/23] fix(registry): gate add_from_registry behind allow_server_add; merge defaults in ListRegistries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the two Codex Gate-3 review findings on PR #555 (spec 070). Finding 1 (security): the add_from_registry MCP op persisted a new upstream without the AllowServerAdd check that the ordinary `add` op enforces, so a user who set allow_server_add=false could still add a server by registry reference. Gate add_from_registry behind the same check. Finding 2 (FR-006 consistency): Runtime.ListRegistries returned a hard-coded legacy Smithery entry for an empty config, or only cfg.Registries when set — diverging from the merged defaults+custom source that search/add use. Route it through registries.SetRegistriesFromConfig so the daemon/API list always shows built-in defaults merged with custom entries. Tests-first for both (ENG-1). Related #555 --- internal/runtime/list_registries_test.go | 61 +++++++++++++++++++ internal/runtime/runtime.go | 43 ++++++------- internal/server/mcp.go | 5 +- internal/server/mcp_add_from_registry_test.go | 26 ++++++++ 4 files changed, 109 insertions(+), 26 deletions(-) create mode 100644 internal/runtime/list_registries_test.go diff --git a/internal/runtime/list_registries_test.go b/internal/runtime/list_registries_test.go new file mode 100644 index 00000000..34f8175d --- /dev/null +++ b/internal/runtime/list_registries_test.go @@ -0,0 +1,61 @@ +package runtime + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// registryIDsFromList extracts the "id" field from the []interface{} that +// Runtime.ListRegistries returns (each element is a map[string]interface{}). +func registryIDsFromList(t *testing.T, list []interface{}) []string { + t.Helper() + ids := make([]string, 0, len(list)) + for _, item := range list { + m, ok := item.(map[string]interface{}) + require.True(t, ok, "each registry must be a map") + id, _ := m["id"].(string) + ids = append(ids, id) + } + return ids +} + +// FR-006 / MCP-800 finding 2: Runtime.ListRegistries must route through the same +// merged source (built-in defaults + user registries, keyed by ID) that +// search/add use — not return the legacy hard-coded Smithery entry for an empty +// config, nor only the custom entries when set. +func TestListRegistries_MergesDefaultsWithCustom(t *testing.T) { + logger := zap.NewNop() + defaults := config.DefaultRegistries() + require.NotEmpty(t, defaults, "built-in defaults must exist") + + // Empty config → built-in defaults, NOT the hard-coded legacy Smithery entry. + rtEmpty := &Runtime{logger: logger, cfg: &config.Config{}} + gotEmpty, err := rtEmpty.ListRegistries() + require.NoError(t, err) + idsEmpty := registryIDsFromList(t, gotEmpty) + assert.Len(t, idsEmpty, len(defaults), "empty config must return exactly the built-in defaults") + for _, d := range defaults { + assert.Contains(t, idsEmpty, d.ID, "built-in default %q must be listed", d.ID) + } + assert.NotContains(t, idsEmpty, "smithery", "legacy hard-coded Smithery must not leak when defaults exist") + + // Custom config → custom entry merges WITH the defaults (does not replace them). + rtCustom := &Runtime{logger: logger, cfg: &config.Config{ + Registries: []config.RegistryEntry{ + {ID: "custom-reg", Name: "Custom", ServersURL: "http://example.test/x", Protocol: "modelcontextprotocol/registry"}, + }, + }} + gotCustom, err := rtCustom.ListRegistries() + require.NoError(t, err) + idsCustom := registryIDsFromList(t, gotCustom) + assert.Contains(t, idsCustom, "custom-reg", "custom registry must appear") + for _, d := range defaults { + assert.Contains(t, idsCustom, d.ID, "built-in default %q must still appear alongside custom", d.ID) + } + assert.Len(t, idsCustom, len(defaults)+1, "custom registry must be additive to defaults") +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 3720ef84..64e10245 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -1438,33 +1438,27 @@ func contains(slice []string, item string) bool { return false } -// ListRegistries returns the list of available MCP server registries (Phase 7) +// ListRegistries returns the list of available MCP server registries (Phase 7). +// +// It routes through the SAME merged source (built-in defaults + user-configured +// registries, keyed by ID) that search/add use via SetRegistriesFromConfig, so +// `mcpproxy registry list` / the Web UI never omit a built-in that is still +// searchable/addable and never show the legacy hard-coded Smithery entry instead +// of the shipped defaults (FR-006 / MCP-800 finding 2). func (r *Runtime) ListRegistries() ([]interface{}, error) { r.mu.RLock() - defer r.mu.RUnlock() + cfg := r.cfg + r.mu.RUnlock() - // Import registries package dynamically to avoid import cycles - // For now, we'll return registries from config or use defaults - registries := r.cfg.Registries - if len(registries) == 0 { - // Return default registry (Smithery) - defaultRegistry := map[string]interface{}{ - "id": "smithery", - "name": "Smithery MCP Registry", - "description": "The official community registry for Model Context Protocol (MCP) servers.", - "url": "https://smithery.ai/protocols", - "servers_url": "https://smithery.ai/api/smithery-protocol-registry", - "tags": []string{"official", "community"}, - "protocol": "modelcontextprotocol/registry", - "count": -1, - } - return []interface{}{defaultRegistry}, nil - } + // Rebuild the effective catalog (defaults merged with custom) — same call the + // search/add paths make — then read it back. + registries.SetRegistriesFromConfig(cfg) + merged := registries.ListRegistries() - // Convert config registries to interface slice - result := make([]interface{}, 0, len(registries)) - for _, reg := range registries { - regMap := map[string]interface{}{ + result := make([]interface{}, 0, len(merged)) + for i := range merged { + reg := &merged[i] + result = append(result, map[string]interface{}{ "id": reg.ID, "name": reg.Name, "description": reg.Description, @@ -1473,8 +1467,7 @@ func (r *Runtime) ListRegistries() ([]interface{}, error) { "tags": reg.Tags, "protocol": reg.Protocol, "count": reg.Count, - } - result = append(result, regMap) + }) } return result, nil diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 7e2b573f..215c2bf5 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -2447,7 +2447,10 @@ func (p *MCPProxyServer) handleUpstreamServers(ctx context.Context, request mcp. // Specific operation security checks switch operation { - case operationAdd: + case operationAdd, "add_from_registry": + // add_from_registry persists a new upstream just like a plain add, so it + // must honor the same AllowServerAdd gate — otherwise the "Let agents add + // servers" setting is bypassable by registry reference (MCP-800 finding 1). if !p.config.AllowServerAdd { p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Adding servers is not allowed", time.Since(startTime).Milliseconds(), args, nil, nil, "") return mcp.NewToolResultError("Adding servers is not allowed"), nil diff --git a/internal/server/mcp_add_from_registry_test.go b/internal/server/mcp_add_from_registry_test.go index eb5bf140..b718c276 100644 --- a/internal/server/mcp_add_from_registry_test.go +++ b/internal/server/mcp_add_from_registry_test.go @@ -121,6 +121,32 @@ func TestHandleUpstreamServers_AddFromRegistry_HappyPath(t *testing.T) { assert.Equal(t, true, server["quarantined"], "new registry server must be quarantined (CN-002)") } +// Security gate: add_from_registry MUST honor AllowServerAdd, exactly like the +// ordinary `add` op. With AllowServerAdd=false a registry-add via MCP must be +// rejected before it can resolve/persist a server — otherwise the gate at +// frontend/settings ("Let agents add servers") is bypassable by registry +// reference (PR #555 Codex review / MCP-800 finding 1). +func TestHandleUpstreamServers_AddFromRegistry_BlockedWhenAddDisallowed(t *testing.T) { + startTestRegistry(t, []map[string]interface{}{ + {"id": "everything", "name": "everything", "installCmd": "npx -y @modelcontextprotocol/server-everything"}, + }) + + srv := newAddFromRegistryTestServer(t) + srv.config.AllowServerAdd = false + + result := callAddFromRegistry(t, srv, map[string]interface{}{ + "operation": "add_from_registry", + "registry": "testreg", + "id": "everything", + }) + + require.True(t, result.IsError, "add_from_registry must be rejected when AllowServerAdd=false") + require.NotEmpty(t, result.Content) + tc, ok := result.Content[0].(mcp.TextContent) + require.True(t, ok, "expected text content") + assert.Contains(t, tc.Text, "Adding servers is not allowed") +} + // Missing required input: the entry declares ${GITHUB_TOKEN} but the request // supplies no env. The handler must return a structured error (isError=true) // carrying the stable cross-surface code and the offending input names (FR-003).