diff --git a/cmd/mcpproxy/main.go b/cmd/mcpproxy/main.go index 1240f3abe..69c90b715 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 000000000..a6e9235c6 --- /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/docs/api/rest-api.md b/docs/api/rest-api.md index 48bae17bb..e64fbf976 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 000000000..848a92033 --- /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) diff --git a/e2e/playwright/registry-add.spec.ts b/e2e/playwright/registry-add.spec.ts new file mode 100644 index 000000000..027eeb650 --- /dev/null +++ b/e2e/playwright/registry-add.spec.ts @@ -0,0 +1,131 @@ +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'; +// 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) { + 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) { + // 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({ timeout: 15000 }); +} + +async function selectRegistryAndSearch(page: Page, registryId: string, query: string) { + const select = page.locator('[data-test="registry-select"]'); + 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(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, REGISTRY_ID, SEARCH_QUERY); + + // 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'); + // 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, requiredRegistry, requiredQuery); + + 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 3b60fed0a..0fc3370b9 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 50e0c76ae..6a341f462 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 86874bf57..8e483241a 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 000000000..7c996bbf9 --- /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' }) + }) +}) diff --git a/internal/cache/manager.go b/internal/cache/manager.go index 215a732d4..b2c907b7c 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 000000000..ea461d288 --- /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") + } +} diff --git a/internal/cliclient/client.go b/internal/cliclient/client.go index 0f4dbe988..70c9b1ae4 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 000000000..ec8442d4a --- /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/config/config.go b/internal/config/config.go index a6761c7b4..60e5cf2e6 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/contracts/types.go b/internal/contracts/types.go index b5f2f274b..c72fbf8a4 100644 --- a/internal/contracts/types.go +++ b/internal/contracts/types.go @@ -829,13 +829,79 @@ 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 +// 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. diff --git a/internal/httpapi/code_exec_test.go b/internal/httpapi/code_exec_test.go index 7256e61d8..3f7e29806 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" @@ -89,8 +90,12 @@ 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 } func (m *mockController) GetManagementService() interface{} { return nil } func (m *mockController) GetRuntime() interface{} { return nil } diff --git a/internal/httpapi/contracts_test.go b/internal/httpapi/contracts_test.go index 2eba00b23..d1c94c539 100644 --- a/internal/httpapi/contracts_test.go +++ b/internal/httpapi/contracts_test.go @@ -305,8 +305,14 @@ 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 } // Version and updates diff --git a/internal/httpapi/registry_resilience_test.go b/internal/httpapi/registry_resilience_test.go new file mode 100644 index 000000000..6627acd95 --- /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 6e100b6f1..427567b67 100644 --- a/internal/httpapi/security_test.go +++ b/internal/httpapi/security_test.go @@ -290,8 +290,12 @@ 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 } 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 17a45dd4f..f8026bfa5 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" @@ -24,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" @@ -114,7 +116,17 @@ 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 + // 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 +631,8 @@ 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) r.Get("/activity", s.handleListActivity) @@ -3980,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 @@ -4029,11 +4057,133 @@ func (s *Server) handleSearchRegistryServers(w http.ResponseWriter, r *http.Requ Total: len(contractServers), Query: query, Tag: tag, + Cache: cacheInfo, } 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, + }, + }) +} + +// 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 { + 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/registries/inputs.go b/internal/registries/inputs.go new file mode 100644 index 000000000..386369c71 --- /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 000000000..477124ed4 --- /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/registry_data.go b/internal/registries/registry_data.go index 7027ba6fa..27123c2e4 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 000000000..7bc516c59 --- /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/registry_key.go b/internal/registries/registry_key.go new file mode 100644 index 000000000..a22a86d51 --- /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 000000000..f5761c291 --- /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 8d7373b42..ccc786252 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" @@ -36,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) } @@ -74,6 +95,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 e90623616..5958d692b 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 @@ -29,4 +34,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/runtime/list_registries_test.go b/internal/runtime/list_registries_test.go new file mode 100644 index 000000000..34f8175d6 --- /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 011c1b0d6..64e102454 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,15 +1467,32 @@ func (r *Runtime) ListRegistries() ([]interface{}, error) { "tags": reg.Tags, "protocol": reg.Protocol, "count": reg.Count, - } - result = append(result, regMap) + }) } 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 +1506,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 +1535,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 +1569,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/runtime/supervisor/actor_pool.go b/internal/runtime/supervisor/actor_pool.go index 5cdc3c24c..1ad6e2e1c 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 2599332b3..24b26abe0 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/server/add_from_registry.go b/internal/server/add_from_registry.go new file mode 100644 index 000000000..09c2944c4 --- /dev/null +++ b/internal/server/add_from_registry.go @@ -0,0 +1,253 @@ +package server + +import ( + "context" + "errors" + "fmt" + "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" +) + +// 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 +} + +// 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. +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 000000000..9b97cb372 --- /dev/null +++ b/internal/server/add_from_registry_test.go @@ -0,0 +1,191 @@ +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" +) + +// --- 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 ---------------------------------- + +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"))) +} diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 8c10801ae..215c2bf5f 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"), @@ -903,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), @@ -2309,6 +2332,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() @@ -2351,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 @@ -2366,7 +2465,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 @@ -2396,6 +2495,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 new file mode 100644 index 000000000..b718c2765 --- /dev/null +++ b/internal/server/mcp_add_from_registry_test.go @@ -0,0 +1,174 @@ +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" +) + +// 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" + + 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 +} + +// 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. SetRegistriesFromConfig +// replaces the global catalog; tests run sequentially so the last writer wins. +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.SetRegistriesFromConfig(&config.Config{ + Registries: []config.RegistryEntry{ + {ID: "testreg", Name: "testreg", ServersURL: srv.URL, Protocol: "modelcontextprotocol/registry"}, + }, + }) +} + +// 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", "installCmd": "npx -y @modelcontextprotocol/server-everything"}, + }) + + srv := newAddFromRegistryTestServer(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)") +} + +// 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). +func TestHandleUpstreamServers_AddFromRegistry_MissingRequiredInput(t *testing.T) { + startTestRegistry(t, []map[string]interface{}{ + {"id": "gh", "name": "gh", "installCmd": "npx github-mcp --token ${GITHUB_TOKEN}"}, + }) + + srv := newAddFromRegistryTestServer(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) +} diff --git a/internal/server/registry_add_e2e_test.go b/internal/server/registry_add_e2e_test.go new file mode 100644 index 000000000..3ae278ff8 --- /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/internal/server/server.go b/internal/server/server.go index e2cc45dbf..283e7e500 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 @@ -2436,10 +2442,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/internal/upstream/client_test.go b/internal/upstream/client_test.go index 93cf8391d..101dfa49a 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/core/client.go b/internal/upstream/core/client.go index 375380bc2..724f87ee5 100644 --- a/internal/upstream/core/client.go +++ b/internal/upstream/core/client.go @@ -75,10 +75,21 @@ type Client struct { // Cached tools list from successful immediate call cachedTools []mcp.Tool - // Stderr monitoring + // 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. 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 @@ -92,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 92be5fe37..809a684d8 100644 --- a/internal/upstream/core/monitoring.go +++ b/internal/upstream/core/monitoring.go @@ -24,17 +24,24 @@ 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 } - // 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", @@ -43,41 +50,55 @@ func (c *Client) StartStderrMonitoring() { // StopStderrMonitoring stops stderr monitoring func (c *Client) StopStderrMonitoring() { - if c.stderrMonitoringCancel != nil { - c.stderrMonitoringCancel() + c.monitoringMu.Lock() + defer c.monitoringMu.Unlock() - // Use a timeout for the wait to prevent hanging - done := make(chan struct{}) - go func() { - c.stderrMonitoringWG.Wait() - close(done) - }() + if c.stderrMonitoringCancel == 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)) - } + c.stderrMonitoringCancel() + done := c.stderrMonitoringDone + c.stderrMonitoringCancel = nil + c.stderrMonitoringDone = nil + if done == nil { + return + } + + // 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)) } } // 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 } - // 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 { @@ -94,29 +115,33 @@ func (c *Client) StartProcessMonitoring() { // StopProcessMonitoring stops process monitoring func (c *Client) StopProcessMonitoring() { - if c.processMonitorCancel != nil { - c.processMonitorCancel() + c.monitoringMu.Lock() + defer c.monitoringMu.Unlock() - // Use a timeout for the wait to prevent hanging - done := make(chan struct{}) - go func() { - c.processMonitorWG.Wait() - close(done) - }() + if c.processMonitorCancel == 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)) - } + 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)) } } // 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 @@ -130,7 +155,7 @@ func (c *Client) monitorProcess() { for { select { - case <-c.processMonitorCtx.Done(): + case <-ctx.Done(): return case <-ticker.C: if isDocker { @@ -141,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 new file mode 100644 index 000000000..6ab87c5af --- /dev/null +++ b/internal/upstream/core/monitoring_race_test.go @@ -0,0 +1,86 @@ +package core + +import ( + "io" + "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() +} + +// 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() +} diff --git a/internal/upstream/managed/client.go b/internal/upstream/managed/client.go index d4c65db52..eee83ec96 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 b2b9dcd20..d80cc1bba 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 427d1138b..da15435c4 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 b66c9895e..acc4c4f21 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 || @@ -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 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 + 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, }) @@ -916,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 } @@ -930,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) } @@ -947,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), @@ -1074,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 } @@ -1104,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 } @@ -1112,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 } @@ -1127,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 @@ -1135,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) { @@ -1155,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) } @@ -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 } @@ -1453,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 @@ -1822,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)) @@ -1905,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)) @@ -1988,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 new file mode 100644 index 000000000..85fc289ff --- /dev/null +++ b/internal/upstream/manager_config_race_test.go @@ -0,0 +1,110 @@ +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() +} + +// 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() +} diff --git a/oas/docs.go b/oas/docs.go index 1a57fce88..323b62d03 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"},"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/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 5667853da..b068fe197 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: @@ -845,6 +851,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: @@ -1435,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: @@ -1458,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: @@ -1518,6 +1558,8 @@ components: type: object contracts.SearchRegistryServersResponse: properties: + cache: + $ref: '#/components/schemas/contracts.RegistryCacheInfo' query: type: string registry_id: @@ -1531,6 +1573,8 @@ components: type: string total: type: integer + unavailable: + $ref: '#/components/schemas/contracts.RegistryUnavailable' type: object contracts.SearchResult: properties: @@ -3263,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 @@ -3321,6 +3399,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 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 000000000..6df2ad738 --- /dev/null +++ b/specs/070-registry-easy-upstream-add/checklists/requirements.md @@ -0,0 +1,38 @@ +# 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. + +## 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. 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 000000000..0503b3136 --- /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 000000000..623c844a3 --- /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 000000000..35d64b292 --- /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 000000000..63e6d6b05 --- /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 000000000..99b61991b --- /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/spec.md b/specs/070-registry-easy-upstream-add/spec.md new file mode 100644 index 000000000..89e205a66 --- /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. 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 000000000..3ec48609c --- /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. + +- [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). + +--- + +## 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.