diff --git a/cmd/mcpproxy/call_cmd.go b/cmd/mcpproxy/call_cmd.go index d75f2c05d..afa3d949e 100644 --- a/cmd/mcpproxy/call_cmd.go +++ b/cmd/mcpproxy/call_cmd.go @@ -130,6 +130,11 @@ Examples: // Intent flags for tool variant commands callIntentReason string callIntentSensitivity string + + // save_to_file flags for tool variant commands (Spec 076) + callSaveToFile string + callSaveFormat string + callSaveOverwrite bool ) // GetCallCommand returns the call command for adding to the root command @@ -188,6 +193,11 @@ func setupToolVariantFlags(cmd *cobra.Command) { cmd.Flags().StringVar(&callIntentReason, "reason", "", "Human-readable explanation for the operation (max 1000 chars)") cmd.Flags().StringVar(&callIntentSensitivity, "sensitivity", "", "Data sensitivity classification: public, internal, private, unknown") + // save_to_file flags (Spec 076) — mirrors the call_tool_* MCP tool params + cmd.Flags().StringVar(&callSaveToFile, "save-to-file", "", "Absolute path under a configured tool_output_roots entry; writes the full untruncated response there instead of printing it") + cmd.Flags().StringVar(&callSaveFormat, "save-format", "", "Format for --save-to-file: text (default) or json") + cmd.Flags().BoolVar(&callSaveOverwrite, "save-overwrite", false, "Overwrite an existing file at --save-to-file instead of failing") + // Mark required flags if err := cmd.MarkFlagRequired("tool-name"); err != nil { panic(fmt.Sprintf("Failed to mark tool-name flag as required: %v", err)) @@ -362,6 +372,21 @@ func runCallToolVariant(toolVariant, operationType string) error { if callIntentReason != "" { variantArgs["intent_reason"] = callIntentReason } + // Add flat save_to_file params (Spec 076). The server rejects + // save_format/save_overwrite without save_to_file too; failing here first + // gives the user the answer without a round trip. + if callSaveToFile == "" && (callSaveFormat != "" || callSaveOverwrite) { + return fmt.Errorf("--save-format/--save-overwrite require --save-to-file") + } + if callSaveToFile != "" { + variantArgs["save_to_file"] = callSaveToFile + } + if callSaveFormat != "" { + variantArgs["save_format"] = callSaveFormat + } + if callSaveOverwrite { + variantArgs["save_overwrite"] = callSaveOverwrite + } // Load configuration globalConfig, err := loadCallConfig() @@ -385,6 +410,9 @@ func runCallToolVariant(toolVariant, operationType string) error { if callIntentReason != "" { fmt.Printf(" Reason: %s\n", callIntentReason) } + if callSaveToFile != "" { + fmt.Printf(" Save to file: %s\n", callSaveToFile) + } fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n") // Detect daemon and use client mode if available diff --git a/docs/configuration.md b/docs/configuration.md index b8582513f..d3b4fc2b9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -96,6 +96,86 @@ MCPProxy looks for configuration in these locations (in order): | `tool_response_limit` | integer | `20000` | Maximum characters in tool responses (0 = unlimited) | | `call_tool_timeout` | string | `"2m"` | Timeout for tool calls (e.g., `"30s"`, `"2m"`, `"5m"`). **Note**: When using agents like Codex or Claude as MCP servers, you may need to increase this timeout significantly, even up to 10 minutes (`"10m"`), as these agents may require longer processing times for complex operations | +### Save Tool Output to File + +`call_tool_read` / `call_tool_write` / `call_tool_destructive` accept an +optional `save_to_file` parameter: instead of returning the (possibly +`tool_response_limit`-truncated) response body, mcpproxy writes the **full, +untruncated** upstream response to a file and returns a short JSON envelope +(`saved_to`, `bytes`, `sha256`, `format`, `content_blocks`, `non_text_blocks`, +`preview`, `truncated_preview`) instead. This is disabled by default — +`save_to_file` requests are rejected until `tool_output_roots` is configured. + +```json +{ + "tool_output_roots": ["/Users/me/mcpproxy-out"], + "tool_output_max_bytes": 52428800 +} +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `tool_output_roots` | array of strings | `[]` (disabled) | Absolute directory paths `save_to_file` is allowed to write under. Each path must be absolute — a relative entry fails config validation. Symlinked roots are resolved once at request time, so pointing a root at (e.g.) a symlinked temp directory works as expected. Configured in the JSON config file only — there is no Settings-UI field for it, since the UI's free-text controls bind a plain string with no array coercion and would corrupt the list on save. The matched root directory (and any missing ancestor directories) is created `0700` on first use as soon as a request matches it, even if that request is ultimately rejected for an unrelated reason (e.g. the response is too large) — a rejected request can still leave an empty root directory behind. | +| `tool_output_max_bytes` | integer | `52428800` (50 MiB) | Maximum size, in bytes, of a single `save_to_file` write. A request that would exceed this is rejected and no file is left behind. | + +**Security notes:** +- Every `save_to_file` path is resolved against `tool_output_roots` with + symlink-safe, separator-bounded prefix matching — `/root/proj` cannot be + satisfied by a sibling path like `/root/proj-evil`, and a symlink that + would walk the resolved path back outside every configured root is + rejected (`ErrOutsideRoots`), as is a symlink sitting at the final target + path itself (`ErrInvalidPath`). The write itself is then confined to the + matched root via a single `*os.Root` handle opened once, immediately after + the root is resolved — every filesystem operation the write performs + (creating parent directories, writing the temp file, renaming into place) + goes through that one handle, never through a path string again. This + closes a symlink or rename planted **inside** the root between the + resolve step and the write, and any replacement of the root directory (or + its ancestors) **after** the handle is open — once the handle is open, a + later swap of the root's own path cannot move it. It does **not** close a + race that replaces an **ancestor** of a configured root in the + microseconds between mcpproxy resolving that ancestor's symlinks and + opening the root — ancestors of a configured root are admin-controlled, + and a same-user process able to win that race could already write + anywhere the mcpproxy process can. +- Directory components under a root must be real directories — if any path + component `save_to_file` would need to create or traverse is itself a + symlink, the write fails (surfaced as a plain filesystem error, e.g. + `mkdirat sub: file exists`) rather than following it. This is a byproduct + of the same `os.Root`-based confinement, not a separate check. +- Root matching is **case-sensitive**, independent of the underlying + filesystem's own case sensitivity — on a case-insensitive filesystem (e.g. + default macOS/Windows volumes), configure `tool_output_roots` using the + same casing your `save_to_file` requests will use, since `/Root/proj` and + `/root/proj` are treated as different, non-matching prefixes even though + the filesystem itself would resolve them to the same directory. +- Saved files are written `0600` (owner read/write only) and any directories + `save_to_file` creates under a root are `0700` (owner-only). +- Writes are atomic (temp file in the destination directory, `fsync`d, then + renamed into place) and default to **not overwriting** an existing file — + pass `"save_overwrite": true` explicitly to replace one. +- A `save_to_file` request against an upstream response that itself came + back as an error is never honored — errors are always returned inline so + they stay visible to the caller. +- A save failure that isn't caught before dispatch — an outside-roots + path, an existing file without `save_overwrite`, or a response over + `tool_output_max_bytes` — occurs **after** the upstream tool has already + executed, and by design the response body is then not returned inline as + a fallback; the caller explicitly asked for a file, so the failure is + always reported as a tool error, never silently forwarded inline. +- The redaction pipeline (`applyOutputSanitisation`, secret-stripping) still + runs on the response **before** it is saved, so a saved file never contains + a secret that redaction would otherwise have stripped. Output-schema + validation (strict-mode blocking against a tool's declared output schema) + and response spotlighting do **not** run on a saved response at all — the + file on disk holds the full, un-spotlighted upstream text as redaction + left it. An agent that reads a saved file back must treat its contents as + untrusted data, the same as any other unvalidated tool output. +- Saving to file does not remove the response body from tool-call history — + the full upstream response is still persisted to the tool-call record + (BoltDB) on a saved call exactly as it is for any other call, so the + existing history retention/redaction rules apply unchanged. + ### Discovery & Health Checks mcpproxy keeps each upstream connection alive and its tool index fresh with two @@ -982,6 +1062,8 @@ Here's a complete configuration example with all major sections: "api_key": "", "tools_limit": 15, "tool_response_limit": 20000, + "tool_output_roots": [], + "tool_output_max_bytes": 52428800, "call_tool_timeout": "2m", "debug_search": false, "enable_prompts": true, diff --git a/docs/configuration/config-file.md b/docs/configuration/config-file.md index 3d3424a79..9850629e7 100644 --- a/docs/configuration/config-file.md +++ b/docs/configuration/config-file.md @@ -31,6 +31,8 @@ MCPProxy uses a JSON configuration file located at `~/.mcpproxy/mcp_config.json` "tool_discovery_interval": "5m", "tools_limit": 15, "tool_response_limit": 20000, + "tool_output_roots": [], + "tool_output_max_bytes": 52428800, "enable_code_execution": false, "code_execution_timeout_ms": 120000, "code_execution_max_tool_calls": 0, @@ -65,6 +67,8 @@ MCPProxy uses a JSON configuration file located at `~/.mcpproxy/mcp_config.json` |--------|------|---------|-------------| | `tools_limit` | integer | `15` | Maximum tools to return in a single request | | `tool_response_limit` | integer | `20000` | Maximum characters in tool response | +| `tool_output_roots` | array of strings | `[]` (disabled) | Absolute directory paths the `save_to_file` param of `call_tool_read`/`call_tool_write`/`call_tool_destructive` may write under. Empty disables the feature. See [Save Tool Output to File](../configuration.md#save-tool-output-to-file). | +| `tool_output_max_bytes` | integer | `52428800` | Maximum bytes a single `save_to_file` write may produce. | ### Tool Discovery & Health Check Intervals diff --git a/docs/setup.md b/docs/setup.md index 60a6258f1..322de765b 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -780,6 +780,13 @@ tail -f ~/Library/Logs/mcpproxy/main.log | grep -E "(github-server|oauth|error)" } ``` +Need the *full* untruncated response instead of a bigger limit? `call_tool_read` / +`call_tool_write` / `call_tool_destructive` support a `save_to_file` parameter +that writes it straight to disk — see [Save Tool Output to File](configuration.md#save-tool-output-to-file). +When mcpproxy runs as a background daemon, `save_to_file` writes as whatever +user/filesystem context the daemon process itself runs under, not the CLI +caller — make sure `tool_output_roots` points somewhere that user can write. + ### OAuth Configuration For servers requiring authentication: diff --git a/frontend/src/views/settings/fields.ts b/frontend/src/views/settings/fields.ts index 674599c54..180d87976 100644 --- a/frontend/src/views/settings/fields.ts +++ b/frontend/src/views/settings/fields.ts @@ -239,6 +239,14 @@ export const GENERAL_FIELDS: SettingField[] = [ }, { key: 'tools_limit', label: 'Search results limit', help: 'How many tools a single tool-search returns to the agent.', control: 'number', min: 1, max: 1000 }, { key: 'tool_response_limit', label: 'Max tool response size (characters)', help: 'Responses larger than this are truncated and cached so the agent can page through them. 0 = never truncate.', control: 'number', min: 0 }, + // Spec 076 — save_to_file. tool_output_roots ([]string) is intentionally + // NOT exposed here: SettingField's free-text controls (including + // 'textarea') bind a plain string and emit a plain string on change, with + // no newline<->array coercion, so wiring it up here would silently + // corrupt the array on save. Configure tool_output_roots in the JSON + // config file directly (see docs/configuration.md) until a dedicated + // array-of-strings control exists. + { key: 'tool_output_max_bytes', label: 'Max save_to_file write size (bytes)', help: 'Maximum size of a single save_to_file write. 0 = use the built-in default (50 MiB).', control: 'number', min: 0, docs: '/configuration#save-tool-output-to-file' }, { key: 'call_tool_timeout', label: 'Tool call timeout', help: 'How long to wait for a single tool call before giving up. e.g. 2m, 90s, 30s.', control: 'duration', placeholder: '2m' }, { key: 'logging.level', diff --git a/internal/config/config.go b/internal/config/config.go index bd9244a3c..13f96cd2a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "strings" "time" @@ -15,6 +16,10 @@ import ( const ( defaultPort = "127.0.0.1:8080" // Localhost-only binding by default for security + // defaultToolOutputMaxBytes is the save_to_file write-size cap applied + // when ToolOutputMaxBytes is unset (0). 50 MiB. + defaultToolOutputMaxBytes int64 = 52428800 + // Routing mode constants (Spec 031) RoutingModeRetrieveTools = "retrieve_tools" // Default: BM25 search via retrieve_tools + call_tool_read/write/destructive RoutingModeDirect = "direct" // All upstream tools exposed directly with serverName__toolName naming @@ -118,6 +123,21 @@ type Config struct { CallToolTimeout Duration `json:"call_tool_timeout" mapstructure:"call-tool-timeout" swaggertype:"string"` MaxResultSizeChars int `json:"max_result_size_chars,omitempty" mapstructure:"max-result-size-chars"` // 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. + // ToolOutputRoots is the whitelist of absolute directory prefixes the + // `save_to_file` parameter on call_tool_read/write/destructive is allowed + // to write under (Spec 076). + // Empty (the default) disables the feature entirely — call_tool_* returns + // a "save_to_file is disabled" error for any save_to_file request. Each + // entry must be an absolute path (validated in ValidateDetailed) and is + // stored filepath.Clean'ed; entries need not exist at startup — they are + // resolved (including symlinks) at the time of each save_to_file call, in + // internal/outputfile. + ToolOutputRoots []string `json:"tool_output_roots,omitempty" mapstructure:"tool-output-roots"` + // ToolOutputMaxBytes caps the size of a single save_to_file write. 0 (the + // default) means "use the built-in default" (50 MiB); negative values are + // a config error. See internal/outputfile.Write. + ToolOutputMaxBytes int64 `json:"tool_output_max_bytes,omitempty" mapstructure:"tool-output-max-bytes"` + // Discovery & health-check cadence (spec 074, #608). Both are *Duration // tri-state pointers: nil = inherit the built-in default; a pointer to 0s = // the loop is disabled; a positive value = that interval. Defaults live only @@ -1139,6 +1159,11 @@ func DefaultConfig() *Config { CallToolTimeout: Duration(2 * time.Minute), // Default 2 minutes for tool calls MaxResultSizeChars: 500000, // Claude Code's inline-response hard max + // save_to_file (Spec 076): disabled by default (no whitelisted roots); + // when enabled via config, writes default-cap at 50 MiB per file. + ToolOutputRoots: nil, + ToolOutputMaxBytes: defaultToolOutputMaxBytes, + // Default secure environment configuration Environment: secureenv.DefaultEnvConfig(), @@ -1384,6 +1409,46 @@ func (c *Config) ValidateDetailed() []ValidationError { }) } + // Validate ToolOutputRoots (save_to_file whitelist, Spec 076): every + // configured root must be an absolute path, and not the filesystem root + // itself. Roots are not required to exist yet. + // + // A root of "/" (or any OS's equivalent fixed point, e.g. "C:\" on + // Windows) would in principle whitelist the entire filesystem — but + // outputfile.Resolve's prefix-matching would in practice reject every + // requested path under it anyway, because Resolve requires + // strings.HasPrefix(target, root+separator), and a root of "/" cleans to + // exactly "/" with nothing after the separator to compare against ("/"+ + // "/" doubles the separator, which no cleaned target path ever starts + // with). So "/" is not a privilege-escalation risk today, but it IS a + // silently-useless config value that matches nothing — reject it here + // with an explicit message rather than let the admin discover the dead + // entry only when every save_to_file call against it mysteriously fails. + for _, root := range c.ToolOutputRoots { + if !filepath.IsAbs(root) { + errors = append(errors, ValidationError{ + Field: "tool_output_roots", + Message: fmt.Sprintf("root %q must be an absolute path", root), + }) + continue + } + cleaned := filepath.Clean(root) + if filepath.Dir(cleaned) == cleaned { + errors = append(errors, ValidationError{ + Field: "tool_output_roots", + Message: fmt.Sprintf("root %q must not be the filesystem root itself — it matches no save_to_file target", root), + }) + } + } + + // Validate ToolOutputMaxBytes + if c.ToolOutputMaxBytes < 0 { + errors = append(errors, ValidationError{ + Field: "tool_output_max_bytes", + Message: "cannot be negative", + }) + } + // Validate timeout if c.CallToolTimeout.Duration() <= 0 { errors = append(errors, ValidationError{ @@ -1602,6 +1667,20 @@ func (c *Config) Validate() error { if c.CallToolTimeout.Duration() <= 0 { c.CallToolTimeout = Duration(2 * time.Minute) // Default to 2 minutes } + // save_to_file (Spec 076): 0 means "use the built-in default", NOT disabled + // (unlike ToolResponseLimit above) — the feature's on/off switch is + // ToolOutputRoots being empty, not this cap. Negative values are caught + // by ValidateDetailed below and never reach here as a successful Validate(). + if c.ToolOutputMaxBytes == 0 { + c.ToolOutputMaxBytes = defaultToolOutputMaxBytes + } + if len(c.ToolOutputRoots) > 0 { + cleaned := make([]string, len(c.ToolOutputRoots)) + for i, root := range c.ToolOutputRoots { + cleaned[i] = filepath.Clean(root) + } + c.ToolOutputRoots = cleaned + } // Apply code execution defaults if c.CodeExecutionTimeoutMs <= 0 { c.CodeExecutionTimeoutMs = 120000 // 2 minutes (120,000ms) diff --git a/internal/config/validation_test.go b/internal/config/validation_test.go index caaf83bfc..2989b9b5d 100644 --- a/internal/config/validation_test.go +++ b/internal/config/validation_test.go @@ -294,6 +294,54 @@ func TestValidateDetailed(t *testing.T) { expectedErrors: 0, errorFields: []string{}, }, + { + name: "tool_output_roots relative path rejected", + config: &Config{ + Listen: ":8080", + ToolsLimit: 15, + ToolResponseLimit: 1000, + CallToolTimeout: Duration(60000000000), + ToolOutputRoots: []string{"relative/path"}, + }, + expectedErrors: 1, + errorFields: []string{"tool_output_roots"}, + }, + { + name: "tool_output_roots absolute path is valid", + config: &Config{ + Listen: ":8080", + ToolsLimit: 15, + ToolResponseLimit: 1000, + CallToolTimeout: Duration(60000000000), + ToolOutputRoots: []string{"/tmp/mcpproxy-out"}, + }, + expectedErrors: 0, + errorFields: []string{}, + }, + { + name: "tool_output_roots filesystem root rejected", + config: &Config{ + Listen: ":8080", + ToolsLimit: 15, + ToolResponseLimit: 1000, + CallToolTimeout: Duration(60000000000), + ToolOutputRoots: []string{"/"}, + }, + expectedErrors: 1, + errorFields: []string{"tool_output_roots"}, + }, + { + name: "tool_output_max_bytes negative rejected", + config: &Config{ + Listen: ":8080", + ToolsLimit: 15, + ToolResponseLimit: 1000, + CallToolTimeout: Duration(60000000000), + ToolOutputMaxBytes: -1, + }, + expectedErrors: 1, + errorFields: []string{"tool_output_max_bytes"}, + }, } for _, tt := range tests { @@ -364,3 +412,52 @@ func TestValidateWithDefaults(t *testing.T) { assert.Equal(t, 0, cfg.ToolResponseLimit) assert.Greater(t, cfg.CallToolTimeout.Duration().Seconds(), 0.0) } + +func TestValidate_ToolOutputMaxBytesDefaultsWhenZero(t *testing.T) { + cfg := &Config{ + ToolsLimit: 15, + ToolResponseLimit: 1000, + CallToolTimeout: Duration(60000000000), + ToolOutputMaxBytes: 0, // unset -> should become the built-in default, NOT stay 0 + } + err := cfg.Validate() + require.NoError(t, err) + assert.EqualValues(t, defaultToolOutputMaxBytes, cfg.ToolOutputMaxBytes) +} + +func TestValidate_ToolOutputRootsCleaned(t *testing.T) { + cfg := &Config{ + ToolsLimit: 15, + ToolResponseLimit: 1000, + CallToolTimeout: Duration(60000000000), + ToolOutputRoots: []string{"/tmp/a/../b/"}, + } + err := cfg.Validate() + require.NoError(t, err) + assert.Equal(t, []string{"/tmp/b"}, cfg.ToolOutputRoots) +} + +func TestValidate_ToolOutputRootsFilesystemRootIsLoadError(t *testing.T) { + cfg := &Config{ + ToolsLimit: 15, + ToolResponseLimit: 1000, + CallToolTimeout: Duration(60000000000), + ToolOutputRoots: []string{"/"}, + } + err := cfg.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "tool_output_roots") + assert.Contains(t, err.Error(), "filesystem root") +} + +func TestValidate_ToolOutputRootsRelativeIsLoadError(t *testing.T) { + cfg := &Config{ + ToolsLimit: 15, + ToolResponseLimit: 1000, + CallToolTimeout: Duration(60000000000), + ToolOutputRoots: []string{"relative/dir"}, + } + err := cfg.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "tool_output_roots") +} diff --git a/internal/outputfile/outputfile.go b/internal/outputfile/outputfile.go new file mode 100644 index 000000000..64b05008d --- /dev/null +++ b/internal/outputfile/outputfile.go @@ -0,0 +1,492 @@ +// Package outputfile resolves and writes the `save_to_file` capability of +// mcpproxy-go's call_tool_read/write/destructive tools (Spec 076): full, +// untruncated upstream tool responses are written to a file under a +// config-whitelisted root instead of being returned (and truncated) inline. +// +// The package is deliberately pure/stdlib-only and side-effect-light so its +// path-validation logic (the security-sensitive part) is fully unit +// testable without touching the MCP server plumbing. +package outputfile + +import ( + cryptorand "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Errors returned by Resolve and Write. Callers should prefix the .Error() +// text with "save_to_file: " when surfacing it to an agent; the messages +// here intentionally reveal nothing about the contents of other roots or +// the filesystem beyond the fact that the given path was rejected. +var ( + // ErrInvalidPath covers malformed requests: empty, containing a NUL + // byte, not absolute, a final path component that is a symlink or any + // other non-regular file, or a request that resolves to exactly a + // configured root (a root is a directory, not a save_to_file target). + ErrInvalidPath = errors.New("save_to_file: invalid path") + // ErrOutsideRoots is returned when the resolved path does not fall + // under any configured root. + ErrOutsideRoots = errors.New("save_to_file: path is outside configured tool_output_roots") + // ErrDisabled is returned when no roots are configured at all. The + // exact wording is part of the tool's public contract (surfaced + // verbatim to agents), so callers should not further wrap this message. + ErrDisabled = errors.New("save_to_file is disabled: configure tool_output_roots") + // ErrExists is returned when the resolved target already exists as a + // regular file and the caller did not request overwrite. + ErrExists = errors.New("save_to_file: file already exists (pass save_overwrite=true to replace it)") + // ErrTooLarge is returned by Write when data exceeds the configured + // tool_output_max_bytes. No file (not even a temp file) is left behind. + ErrTooLarge = errors.New("save_to_file: data exceeds tool_output_max_bytes") + // ErrRootUnavailable is returned when a configured root cannot be opened, + // created, or verified to still be the same directory Resolve just + // finished resolving. Resolve performs an identity check immediately + // after opening the root (see Resolve's doc comment) — a mismatch means + // the path was replaced with a symlink (or a different directory) in the + // narrow window between symlink resolution and the open, so this fails + // closed rather than trusting a directory that may not be the one an + // operator configured. Also returned when os.MkdirAll or os.OpenRoot on + // the root itself fails outright. + ErrRootUnavailable = errors.New("save_to_file: configured root is unavailable or was replaced") + // ErrWriteFailed wraps a lower-level filesystem error encountered while + // writing the target file itself inside an already-opened root (creating + // intermediate directories, the temp file, fsync, the pre-rename + // existence re-check, or the final rename) — as opposed to a + // request-shape error like ErrInvalidPath, ErrOutsideRoots, or a + // root-level failure (ErrRootUnavailable). + ErrWriteFailed = errors.New("save_to_file: failed to write file") +) + +// Info describes a file successfully written by Write. +type Info struct { + Path string + Bytes int64 + SHA256 string +} + +// Target is a save_to_file destination that has passed Resolve's whitelist +// checks. Handle and Rel — never Path or Root as strings — are what Write +// actually uses: Resolve itself opens Handle (an *os.Root scoped to the +// resolved root directory) and every filesystem operation Write performs +// goes through Handle via Rel, so a symlink planted anywhere along Root +// AFTER Resolve returns — including at Root's own path — cannot move the +// write outside the directory Handle was opened against (see the TOCTOU +// note on Write, and Resolve's identity check). +type Target struct { + // Path is the fully resolved (symlink-free), absolute path — retained + // for display in errors/envelopes/history only. Never open this path + // directly; always go through Handle+Rel (see Write). + Path string + // Root is the absolute, symlink-resolved directory (one of the + // configured tool_output_roots, after its own symlinks are resolved) + // that Path falls under — retained for display/logging only, exactly + // like Path. Never reopened by path; Handle is the live reference. + Root string + // Rel is Path relative to Root — the name Write passes to Handle's + // methods. + Rel string + // Handle is the *os.Root opened by Resolve against Root, already + // identity-checked (see Resolve). The caller MUST close it — via + // Close() — once done with Target, typically deferred immediately after + // a successful Resolve call. A Resolve error never returns a Target with + // a non-nil Handle still open (any handle opened partway through a + // failing Resolve call is closed before the error returns). + Handle *os.Root +} + +// Close releases the *os.Root handle opened by Resolve. Safe to call on a +// zero Target or one whose Handle is nil (a no-op). Callers must defer +// Close() immediately after a successful Resolve call (see writeSaveToFile +// in internal/server/content_forward.go); Resolve itself never leaks an +// open handle on an error return, so Close is only ever needed after +// success. +func (t Target) Close() error { + if t.Handle == nil { + return nil + } + return t.Handle.Close() +} + +// Resolve validates a requested absolute path against the configured +// whitelist roots and returns a Target — carrying an already-opened, +// identity-checked *os.Root handle (Target.Handle) — describing the +// fully-resolved (symlink-free) destination to write to, or an error +// explaining why the request was rejected. On any error return, no handle +// is left open (see step 6 below and Target.Handle's doc comment). +// +// Rules, applied in order: +// 1. roots empty -> ErrDisabled +// 2. requested empty, contains a NUL byte, or is +// not absolute -> ErrInvalidPath +// 3. requested is filepath.Clean'ed +// 4. the deepest EXISTING ancestor DIRECTORY of the cleaned path is +// resolved via filepath.EvalSymlinks (so a symlinked directory +// anywhere along the path — e.g. macOS's /var -> /private/var, or an +// attacker-controlled symlink planted inside a root — cannot be used to +// escape the whitelist), and the non-existing remainder (including the +// final path component, which is NEVER symlink-resolved so a symlink +// sitting exactly at the target path is still visible to step 7) is +// re-joined onto it +// 5. each configured root is resolved through the SAME deepest-existing- +// ancestor logic (full EvalSymlinks when the root itself already +// exists — including a symlinked root directory, which is intentionally +// honored — or ancestor-resolved-and-rejoined when it does not yet +// exist, so a not-yet-created root under a symlinked ancestor, e.g. +// macOS's /tmp -> /private/tmp, is not wrongly rejected). The resolved +// target must equal one resolved root, or fall strictly inside it (a +// separator-bounded prefix match, so "/r/proj-evil" is never accepted +// against root "/r/proj") — otherwise ErrOutsideRoots. A target that +// resolves to EXACTLY a configured root is always ErrInvalidPath (a +// root is a directory to write under, never itself the file to write) +// 6. the matched root is created if it does not exist yet +// (os.MkdirAll(root, 0700) — configured roots need not exist at +// startup), then opened via os.OpenRoot and identity-checked: the +// opened directory's Stat(".") is compared (os.SameFile) against a +// fresh os.Lstat of the root path. A mismatch — the root path is now a +// symlink, or resolves to a different directory than what was just +// opened — closes the handle and fails with ErrRootUnavailable rather +// than trusting a directory that may not be the one that was just +// validated. Any other MkdirAll/OpenRoot/Stat/Lstat failure also fails +// with ErrRootUnavailable. See the package-level security note below +// this function for exactly what this check does and does not close. +// 7. if the resolved target already exists: +// - a symlink, or any other non-regular file (device, socket, dir, …) +// -> ErrInvalidPath (never overwritten, even with save_overwrite=true) +// - a regular file and !overwrite -> ErrExists +// - a regular file and overwrite -> accepted +// +// # Security: what the identity check closes, and what it does not +// +// Once Resolve returns a Target, every filesystem operation Write performs +// goes through Target.Handle — an already-open fd/handle — never through a +// path string. That structurally closes the classic TOCTOU race for +// everything that happens AFTER the handle is open: a symlink or rename +// planted inside the root, or a replacement of the root directory itself or +// any of its ancestors, between Resolve returning and Write running, cannot +// move where the write lands (the open handle keeps referencing the +// original directory even if its path is later replaced). +// +// What this does NOT close is the sub-microsecond window between this +// function's own symlink resolution (steps 4-5, via filepath.EvalSymlinks) +// and the os.OpenRoot call in step 6: a same-user process that wins that +// exact race — swapping a directory component for a symlink in the +// instant between EvalSymlinks and OpenRoot — could, in principle, get +// OpenRoot to open the wrong directory undetected if it also removes the +// symlink again before the identity check's os.Lstat runs. Ancestors of a +// configured root are administrator-controlled, and a same-user process +// capable of winning that race against its own filesystem already has +// every other means of writing anywhere that user can write — so this is +// not treated as a meaningful escalation, only documented as a residual. +// The identity check DOES reliably catch the realistic version of this +// attack (a symlink planted at the root's own path and left in place, e.g. +// "rename the real root aside, drop a symlink at its former path"), because +// that symlink is still there for the os.Lstat comparison to see. +func Resolve(roots []string, requested string, overwrite bool) (Target, error) { + if len(roots) == 0 { + return Target{}, ErrDisabled + } + if requested == "" || strings.IndexByte(requested, 0) >= 0 { + return Target{}, ErrInvalidPath + } + if !filepath.IsAbs(requested) { + return Target{}, ErrInvalidPath + } + + cleaned := filepath.Clean(requested) + + resolvedTarget, err := resolveTargetPath(cleaned) + if err != nil { + return Target{}, ErrInvalidPath + } + + matchedRoot := "" + for _, root := range roots { + resolvedRoot := resolveRootPath(root) + if resolvedTarget == resolvedRoot { + return Target{}, fmt.Errorf("%w: target must be a file inside a root, not the root itself", ErrInvalidPath) + } + if matchedRoot == "" && strings.HasPrefix(resolvedTarget, resolvedRoot+string(os.PathSeparator)) { + matchedRoot = resolvedRoot + } + } + if matchedRoot == "" { + return Target{}, ErrOutsideRoots + } + + // Configured roots need not exist at startup (config.go's ToolOutputRoots + // doc comment promises this) — os.OpenRoot below cannot create the + // directory itself, so create it here, under the already-ancestor-resolved + // matchedRoot, before opening it. + if err := os.MkdirAll(matchedRoot, 0o700); err != nil { + return Target{}, fmt.Errorf("%w: %v", ErrRootUnavailable, err) + } + + // Open the root HERE, immediately after it is matched/created, and carry + // the handle in the returned Target — Write must never re-derive a root + // from a path string again (that was the TOCTOU this closes: re-opening + // by path after Resolve returned gave a symlink swapped in during the gap + // something to be followed). See the package-level security note above + // this function. + handle, err := os.OpenRoot(matchedRoot) + if err != nil { + return Target{}, fmt.Errorf("%w: %v", ErrRootUnavailable, err) + } + openedInfo, err := handle.Stat(".") + if err != nil { + _ = handle.Close() + return Target{}, fmt.Errorf("%w: %v", ErrRootUnavailable, err) + } + diskInfo, err := os.Lstat(matchedRoot) + if err != nil { + _ = handle.Close() + return Target{}, fmt.Errorf("%w: %v", ErrRootUnavailable, err) + } + if !os.SameFile(openedInfo, diskInfo) { + // The root path no longer names the directory that was just opened + // (e.g. it is now a symlink, or a different directory) — see the + // "Security" note above: this is exactly the identity check that + // catches a root swapped-for-a-symlink between resolution and open. + _ = handle.Close() + return Target{}, ErrRootUnavailable + } + + if fi, statErr := os.Lstat(resolvedTarget); statErr == nil { + if fi.Mode()&os.ModeSymlink != 0 || !fi.Mode().IsRegular() { + _ = handle.Close() + return Target{}, ErrInvalidPath + } + if !overwrite { + _ = handle.Close() + return Target{}, ErrExists + } + } else if !os.IsNotExist(statErr) { + // Permission error or similar — fail closed rather than silently + // proceeding against a path we could not stat. + _ = handle.Close() + return Target{}, ErrInvalidPath + } + + rel, err := filepath.Rel(matchedRoot, resolvedTarget) + if err != nil { + // Should not happen: resolvedTarget was just proven to be matchedRoot + // or a separator-bounded descendant of it. + _ = handle.Close() + return Target{}, ErrInvalidPath + } + + return Target{Path: resolvedTarget, Root: matchedRoot, Rel: rel, Handle: handle}, nil +} + +// resolveTargetPath finds the deepest existing ancestor DIRECTORY of +// cleaned (always a proper ancestor — the final path component itself is +// never passed to EvalSymlinks so callers can still detect it being a +// symlink via Lstat afterwards), resolves that ancestor's symlinks, and +// re-joins the non-existing remainder (including the final component) +// literally onto the resolved ancestor. +func resolveTargetPath(cleaned string) (string, error) { + return resolveDeepestExistingAncestor(cleaned, true) +} + +// resolveRootPath resolves a single configured root for comparison against a +// resolved target. If the root already exists (including as a symlink to a +// directory — intentionally honored, an admin explicitly configured it), +// its symlinks are fully resolved. If it does not exist yet, it is resolved +// through its deepest existing ancestor and the non-existing remainder is +// rejoined literally — the same escape-safe logic Resolve applies to +// targets — so a not-yet-created root under a symlinked ancestor (e.g. +// macOS's /tmp -> /private/tmp) is not silently mismatched. +func resolveRootPath(root string) string { + resolved, err := resolveDeepestExistingAncestor(filepath.Clean(root), false) + if err != nil { + return filepath.Clean(root) + } + return resolved +} + +// resolveDeepestExistingAncestor climbs from cleaned toward the filesystem +// root until it finds a path component that currently exists, resolves that +// existing ancestor's symlinks via filepath.EvalSymlinks, and rejoins the +// non-existing remainder onto the resolved ancestor literally (never +// symlink-resolved, since it doesn't exist to resolve). +// +// When excludeFinal is true, the final path component of cleaned is always +// treated as part of the "remainder" — even if it currently exists on disk +// — so it is never itself passed through EvalSymlinks. This is what target +// resolution needs: a symlink sitting exactly at the target path must +// remain visible to a subsequent Lstat check rather than being silently +// followed. When excludeFinal is false, an existing full path is resolved +// end-to-end (equivalent to a plain EvalSymlinks(cleaned)) — what root +// resolution needs, since a fully-existing symlinked root is an intentional +// admin configuration, not an attack. +func resolveDeepestExistingAncestor(cleaned string, excludeFinal bool) (string, error) { + dir := cleaned + var remainder []string + if excludeFinal { + dir = filepath.Dir(cleaned) + remainder = []string{filepath.Base(cleaned)} + } + + for { + if _, statErr := os.Lstat(dir); statErr == nil { + break + } else if !os.IsNotExist(statErr) { + return "", statErr + } + parent := filepath.Dir(dir) + if parent == dir { + // Reached the filesystem root without it "existing" — should not + // happen in practice, but stop climbing rather than loop forever. + break + } + remainder = append([]string{filepath.Base(dir)}, remainder...) + dir = parent + } + + resolvedDir, err := filepath.EvalSymlinks(dir) + if err != nil { + return "", err + } + + result := resolvedDir + for _, part := range remainder { + result = filepath.Join(result, part) + } + return result, nil +} + +// Write atomically writes data under target: a temp file is created inside +// target.Handle (with a randomly-suffixed name), written, fsync'ed, and +// renamed into place. Every filesystem operation goes through +// target.Handle — the *os.Root Resolve already opened and identity-checked +// — and Rel; Write never touches target.Root or target.Path as strings, and +// never calls os.OpenRoot itself. That is what closes the TOCTOU: even if a +// local process replaces a directory component of target.Rel with a +// symlink after Resolve returned (the classic check-then-use race: Resolve +// validates "/sub/f.txt", then something creates "/sub" as a +// symlink to an arbitrary location before Write runs), Handle's +// per-component containment check refuses to follow it outside the +// directory it was opened against — and a swap of the root's OWN path after +// Resolve returned cannot move Handle's fd at all, since Handle no longer +// looks anything up by that path. This closes the race that a plain +// os.MkdirAll + os.CreateTemp + os.Rename sequence (which follows symlinks +// at every step, the same as any other os/syscall path lookup) cannot +// close, and closes it more completely than re-opening the root by path in +// Write would (that still left the root's own path open to a swap between +// Resolve and Write — see Resolve's package-level security note for what +// remains a residual after this fix). +// +// Write does NOT close target.Handle — the caller owns the handle's +// lifecycle (opened by Resolve, closed by the caller via Target.Close()), +// since a caller may reasonably call Write more than once against the same +// resolved Target within one held-open root. +// +// maxBytes <= 0 disables the size check. When data exceeds maxBytes, Write +// returns ErrTooLarge WITHOUT creating any file, temp or otherwise. +// +// When overwrite is false, Write re-checks that the target does not exist +// immediately before the rename (in addition to any check the caller made +// via Resolve) and fails with ErrExists if it now does; a small window +// between that check and the rename remains (renaming over a +// concurrently-created file of the same name), which — unlike the +// directory-escape race above — cannot move the write itself outside +// target.Root, so it is left as an accepted, documented residual race +// rather than solved with exclusive-create semantics on the final name +// (which would require choosing new not-quite-atomic-either semantics for +// the caller-visible rename step). +// +// Every other filesystem error Write encounters (creating intermediate +// directories, opening/writing/syncing/closing the temp file, the +// pre-rename existence re-check, or the rename itself) is wrapped as +// fmt.Errorf("%w: %v", ErrWriteFailed, err) so callers can distinguish "the +// write itself failed" from the request-shape errors Resolve returns. +// +// Intermediate directories MkdirAll creates under target.Root are NOT +// cleaned up if a later step in this same Write call fails (only the temp +// file itself is removed via removeTemp) — an accepted residual; a +// half-created directory chain with no file in it is not a meaningful +// disclosure or escape and cleaning it up correctly would need to track +// exactly which components this call created versus already existed. +func Write(target Target, data []byte, maxBytes int64, overwrite bool) (Info, error) { + if maxBytes > 0 && int64(len(data)) > maxBytes { + return Info{}, ErrTooLarge + } + if target.Handle == nil { + return Info{}, fmt.Errorf("%w: target has no open root handle (must come from Resolve)", ErrRootUnavailable) + } + root := target.Handle + + relDir := filepath.Dir(target.Rel) + if relDir != "." && relDir != "" { + if err := root.MkdirAll(relDir, 0o700); err != nil { + return Info{}, fmt.Errorf("%w: %v", ErrWriteFailed, err) + } + } + + tmpRel, err := randomTempName(relDir) + if err != nil { + return Info{}, fmt.Errorf("%w: %v", ErrWriteFailed, err) + } + + tmpFile, err := root.OpenFile(tmpRel, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return Info{}, fmt.Errorf("%w: %v", ErrWriteFailed, err) + } + removeTemp := func() { _ = root.Remove(tmpRel) } + + if _, err := tmpFile.Write(data); err != nil { + _ = tmpFile.Close() + removeTemp() + return Info{}, fmt.Errorf("%w: %v", ErrWriteFailed, err) + } + if err := tmpFile.Sync(); err != nil { + _ = tmpFile.Close() + removeTemp() + return Info{}, fmt.Errorf("%w: %v", ErrWriteFailed, err) + } + if err := tmpFile.Close(); err != nil { + removeTemp() + return Info{}, fmt.Errorf("%w: %v", ErrWriteFailed, err) + } + + if !overwrite { + if _, statErr := root.Lstat(target.Rel); statErr == nil { + removeTemp() + return Info{}, ErrExists + } else if !os.IsNotExist(statErr) { + removeTemp() + return Info{}, fmt.Errorf("%w: %v", ErrWriteFailed, statErr) + } + } + + if err := root.Rename(tmpRel, target.Rel); err != nil { + removeTemp() + return Info{}, fmt.Errorf("%w: %v", ErrWriteFailed, err) + } + + sum := sha256.Sum256(data) + return Info{ + Path: target.Path, + Bytes: int64(len(data)), + SHA256: hex.EncodeToString(sum[:]), + }, nil +} + +// randomTempName builds a ".mcpproxy-.tmp" name inside dir +// (dir == "." or "" means "at the root of the os.Root"). os.CreateTemp is +// not usable here — it is not root-scoped — so the random suffix is +// generated by hand from crypto/rand. +func randomTempName(dir string) (string, error) { + var buf [16]byte + if _, err := cryptorand.Read(buf[:]); err != nil { + return "", err + } + name := ".mcpproxy-" + hex.EncodeToString(buf[:]) + ".tmp" + if dir == "." || dir == "" { + return name, nil + } + return filepath.Join(dir, name), nil +} diff --git a/internal/outputfile/outputfile_test.go b/internal/outputfile/outputfile_test.go new file mode 100644 index 000000000..fac66fe1b --- /dev/null +++ b/internal/outputfile/outputfile_test.go @@ -0,0 +1,693 @@ +package outputfile + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolve_UnderRootOK(t *testing.T) { + root := t.TempDir() + requested := filepath.Join(root, "sub", "file.txt") + + got, err := Resolve([]string{root}, requested, false) + if err != nil { + t.Fatalf("Resolve() error = %v, want nil", err) + } + wantRoot, _ := filepath.EvalSymlinks(root) + if got.Root != wantRoot { + t.Fatalf("Resolve().Root = %q, want %q", got.Root, wantRoot) + } + wantPrefix := wantRoot + string(os.PathSeparator) + if !strings.HasPrefix(got.Path, wantPrefix) { + t.Fatalf("Resolve().Path = %q, want a path under %q", got.Path, wantRoot) + } + if got.Rel != filepath.Join("sub", "file.txt") { + t.Fatalf("Resolve().Rel = %q, want %q", got.Rel, filepath.Join("sub", "file.txt")) + } +} + +// TestResolve_RootItselfRejected pins the rule that a request resolving to +// exactly a configured root is rejected — save_to_file must target a file +// INSIDE a root, not the root directory itself. (Previously this was +// accepted — see the removed TestResolve_RootItselfOK.) +func TestResolve_RootItselfRejected(t *testing.T) { + base := t.TempDir() + root := filepath.Join(base, "exact-root-file") + + _, err := Resolve([]string{root}, root, false) + if !errors.Is(err, ErrInvalidPath) { + t.Fatalf("Resolve() error = %v, want ErrInvalidPath", err) + } +} + +func TestResolve_PrefixBoundaryRejected(t *testing.T) { + base := t.TempDir() + root := filepath.Join(base, "proj") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + evil := filepath.Join(base, "proj-evil", "f") + + _, err := Resolve([]string{root}, evil, false) + if !errors.Is(err, ErrOutsideRoots) { + t.Fatalf("Resolve() error = %v, want ErrOutsideRoots", err) + } +} + +func TestResolve_DotDotEscapeRejected(t *testing.T) { + root := t.TempDir() + escaped := filepath.Join(root, "..", "evil", "file.txt") + + _, err := Resolve([]string{root}, escaped, false) + if !errors.Is(err, ErrOutsideRoots) { + t.Fatalf("Resolve() error = %v, want ErrOutsideRoots", err) + } +} + +func TestResolve_SymlinkedDirectoryInsideRootEscapes(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + + link := filepath.Join(root, "link") + if err := os.Symlink(outside, link); err != nil { + t.Skipf("symlinks unsupported: %v", err) + } + + requested := filepath.Join(link, "file.txt") + _, err := Resolve([]string{root}, requested, false) + if !errors.Is(err, ErrOutsideRoots) { + t.Fatalf("Resolve() error = %v, want ErrOutsideRoots", err) + } +} + +func TestResolve_SymlinkedRootAccepted(t *testing.T) { + real := t.TempDir() + base := t.TempDir() + rootLink := filepath.Join(base, "root-link") + if err := os.Symlink(real, rootLink); err != nil { + t.Skipf("symlinks unsupported: %v", err) + } + + requested := filepath.Join(rootLink, "file.txt") + got, err := Resolve([]string{rootLink}, requested, false) + if err != nil { + t.Fatalf("Resolve() error = %v, want nil", err) + } + wantReal, _ := filepath.EvalSymlinks(real) + want := filepath.Join(wantReal, "file.txt") + if got.Path != want { + t.Fatalf("Resolve().Path = %q, want %q", got.Path, want) + } + if got.Root != wantReal { + t.Fatalf("Resolve().Root = %q, want %q", got.Root, wantReal) + } + if got.Rel != "file.txt" { + t.Fatalf("Resolve().Rel = %q, want %q", got.Rel, "file.txt") + } +} + +// TestResolve_RootMissingUnderSymlinkedAncestor pins the rule that a +// configured root that does not exist YET, but whose ancestor is a symlink +// (e.g. macOS's /tmp -> /private/tmp), must still be resolved and matched +// correctly instead of being wrongly rejected as ErrOutsideRoots. +// Hand-builds the symlinked-ancestor scenario so it runs on every OS with +// symlink support, not just macOS. This only pins Resolve's own path +// resolution — see TestWrite_RootCreatedWhenMissing in this file for the +// end-to-end Write pin (the root not existing yet must not make Write fail +// with "no such file or directory"). +func TestResolve_RootMissingUnderSymlinkedAncestor(t *testing.T) { + real := t.TempDir() + parent := t.TempDir() + link := filepath.Join(parent, "link") + if err := os.Symlink(real, link); err != nil { + t.Skipf("symlinks unsupported: %v", err) + } + + // root itself ("link/out") does not exist — only its symlinked + // ancestor ("link" -> real) does. + root := filepath.Join(link, "out") + requested := filepath.Join(root, "f.txt") + + got, err := Resolve([]string{root}, requested, false) + if err != nil { + t.Fatalf("Resolve() error = %v, want nil (root missing under a symlinked ancestor must still resolve)", err) + } + wantReal, _ := filepath.EvalSymlinks(real) + wantRoot := filepath.Join(wantReal, "out") + if got.Root != wantRoot { + t.Fatalf("Resolve().Root = %q, want %q", got.Root, wantRoot) + } + wantPath := filepath.Join(wantRoot, "f.txt") + if got.Path != wantPath { + t.Fatalf("Resolve().Path = %q, want %q", got.Path, wantPath) + } + if got.Rel != "f.txt" { + t.Fatalf("Resolve().Rel = %q, want %q", got.Rel, "f.txt") + } +} + +func TestResolve_FinalTargetSymlinkRejected(t *testing.T) { + root := t.TempDir() + other := t.TempDir() + dest := filepath.Join(other, "dest.txt") + if err := os.WriteFile(dest, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, "leaf.txt") + if err := os.Symlink(dest, link); err != nil { + t.Skipf("symlinks unsupported: %v", err) + } + + _, err := Resolve([]string{root}, link, true) + if !errors.Is(err, ErrInvalidPath) { + t.Fatalf("Resolve() error = %v, want ErrInvalidPath (even with save_overwrite=true)", err) + } +} + +func TestResolve_RelativeRejected(t *testing.T) { + root := t.TempDir() + _, err := Resolve([]string{root}, "relative/path.txt", false) + if !errors.Is(err, ErrInvalidPath) { + t.Fatalf("Resolve() error = %v, want ErrInvalidPath", err) + } +} + +func TestResolve_NULByteRejected(t *testing.T) { + root := t.TempDir() + _, err := Resolve([]string{root}, root+"/f\x00ile", false) + if !errors.Is(err, ErrInvalidPath) { + t.Fatalf("Resolve() error = %v, want ErrInvalidPath", err) + } +} + +func TestResolve_EmptyRequestedRejected(t *testing.T) { + root := t.TempDir() + _, err := Resolve([]string{root}, "", false) + if !errors.Is(err, ErrInvalidPath) { + t.Fatalf("Resolve() error = %v, want ErrInvalidPath", err) + } +} + +func TestResolve_RootsEmptyDisabled(t *testing.T) { + _, err := Resolve(nil, "/tmp/whatever", false) + if !errors.Is(err, ErrDisabled) { + t.Fatalf("Resolve() error = %v, want ErrDisabled", err) + } + if err.Error() != "save_to_file is disabled: configure tool_output_roots" { + t.Fatalf("Resolve() error text = %q, want exact contract message", err.Error()) + } +} + +func TestResolve_ExistsNoOverwriteRejected(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "existing.txt") + if err := os.WriteFile(target, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := Resolve([]string{root}, target, false) + if !errors.Is(err, ErrExists) { + t.Fatalf("Resolve() error = %v, want ErrExists", err) + } +} + +func TestResolve_ExistsOverwriteAccepted(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "existing.txt") + if err := os.WriteFile(target, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + + got, err := Resolve([]string{root}, target, true) + if err != nil { + t.Fatalf("Resolve() error = %v, want nil", err) + } + if got.Path == "" { + t.Fatal("Resolve() returned empty Path") + } +} + +func TestResolve_MissingIntermediateDirsOK(t *testing.T) { + root := t.TempDir() + requested := filepath.Join(root, "a", "b", "c", "file.txt") + + got, err := Resolve([]string{root}, requested, false) + if err != nil { + t.Fatalf("Resolve() error = %v, want nil", err) + } + wantRoot, _ := filepath.EvalSymlinks(root) + wantPath := filepath.Join(wantRoot, "a", "b", "c", "file.txt") + if got.Path != wantPath { + t.Fatalf("Resolve().Path = %q, want %q", got.Path, wantPath) + } + wantRel := filepath.Join("a", "b", "c", "file.txt") + if got.Rel != wantRel { + t.Fatalf("Resolve().Rel = %q, want %q", got.Rel, wantRel) + } +} + +func TestWrite_HappyPath(t *testing.T) { + root := t.TempDir() + target, err := Resolve([]string{root}, filepath.Join(root, "out.txt"), false) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + data := []byte("hello world") + + info, err := Write(target, data, 0, false) + if err != nil { + t.Fatalf("Write() error = %v", err) + } + if info.Path != target.Path { + t.Errorf("info.Path = %q, want %q", info.Path, target.Path) + } + if info.Bytes != int64(len(data)) { + t.Errorf("info.Bytes = %d, want %d", info.Bytes, len(data)) + } + sum := sha256.Sum256(data) + wantSHA := hex.EncodeToString(sum[:]) + if info.SHA256 != wantSHA { + t.Errorf("info.SHA256 = %q, want %q", info.SHA256, wantSHA) + } + + got, err := os.ReadFile(target.Path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(data) { + t.Errorf("file content = %q, want %q", got, data) + } + + // Files are written 0600, not world/group-readable. + fi, err := os.Stat(target.Path) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o600 { + t.Errorf("file mode = %v, want 0600", fi.Mode().Perm()) + } +} + +func TestWrite_TooLargeLeavesNoFile(t *testing.T) { + root := t.TempDir() + target, err := Resolve([]string{root}, filepath.Join(root, "out.txt"), false) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + data := []byte("0123456789") + + _, err = Write(target, data, 5, false) + if !errors.Is(err, ErrTooLarge) { + t.Fatalf("Write() error = %v, want ErrTooLarge", err) + } + + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("directory not empty after ErrTooLarge (no temp file must remain): %v", entries) + } +} + +// tool_output_max_bytes boundary — exactly at the limit is allowed. +func TestWrite_ExactlyMaxBytesAllowed(t *testing.T) { + root := t.TempDir() + target, err := Resolve([]string{root}, filepath.Join(root, "out.txt"), false) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + data := []byte("0123456789") + + info, err := Write(target, data, int64(len(data)), false) + if err != nil { + t.Fatalf("Write() error = %v, want nil when len(data) == maxBytes", err) + } + if info.Bytes != int64(len(data)) { + t.Errorf("info.Bytes = %d, want %d", info.Bytes, len(data)) + } +} + +// maxBytes <= 0 disables the size check entirely. +func TestWrite_NonPositiveMaxBytesMeansNoLimit(t *testing.T) { + root := t.TempDir() + data := []byte(strings.Repeat("z", 100000)) + + for _, maxBytes := range []int64{0, -1} { + target, err := Resolve([]string{root}, filepath.Join(root, "out.txt"), true) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if _, err := Write(target, data, maxBytes, true); err != nil { + t.Fatalf("Write() error = %v with maxBytes=%d, want nil (no limit)", err, maxBytes) + } + } +} + +func TestWrite_MissingIntermediateDirsCreated(t *testing.T) { + root := t.TempDir() + target, err := Resolve([]string{root}, filepath.Join(root, "a", "b", "c", "out.txt"), false) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + + if _, err := Write(target, []byte("x"), 0, false); err != nil { + t.Fatalf("Write() error = %v", err) + } + if _, err := os.Stat(target.Path); err != nil { + t.Fatalf("expected file to exist: %v", err) + } + // Intermediate directories are created 0700, not world/group-readable. + fi, err := os.Stat(filepath.Join(root, "a")) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o700 { + t.Errorf("dir mode = %v, want 0700", fi.Mode().Perm()) + } +} + +func TestWrite_OverwriteReplacesContent(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "out.txt") + target, err := Resolve([]string{root}, path, false) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if _, err := Write(target, []byte("old"), 0, false); err != nil { + t.Fatal(err) + } + + target2, err := Resolve([]string{root}, path, true) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + info, err := Write(target2, []byte("new-content"), 0, true) + if err != nil { + t.Fatalf("Write() overwrite error = %v", err) + } + got, _ := os.ReadFile(path) + if string(got) != "new-content" { + t.Errorf("file content = %q, want %q", got, "new-content") + } + if info.Bytes != int64(len("new-content")) { + t.Errorf("info.Bytes = %d, want %d", info.Bytes, len("new-content")) + } +} + +func TestWrite_NoOverwriteExistingFails(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "out.txt") + if err := os.WriteFile(path, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + + // Resolve itself rejects an existing file without overwrite (ErrExists), + // so build the Target by hand (with a real opened Handle — Write requires + // one) to exercise Write's own re-check directly. + handle, err := os.OpenRoot(root) + if err != nil { + t.Fatal(err) + } + defer func() { _ = handle.Close() }() + target := Target{Path: path, Root: root, Rel: "out.txt", Handle: handle} + _, err = Write(target, []byte("new"), 0, false) + if !errors.Is(err, ErrExists) { + t.Fatalf("Write() error = %v, want ErrExists", err) + } + // Content must be untouched. + got, _ := os.ReadFile(path) + if string(got) != "old" { + t.Errorf("file content changed despite ErrExists: %q", got) + } + // No stray temp file left behind. + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("expected exactly 1 entry in dir, got %d: %v", len(entries), entries) + } +} + +func TestWrite_NoTempFileLeftOnSuccess(t *testing.T) { + root := t.TempDir() + target, err := Resolve([]string{root}, filepath.Join(root, "out.txt"), false) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if _, err := Write(target, []byte("data"), 0, false); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != "out.txt" { + t.Fatalf("unexpected directory contents after Write: %v", entries) + } +} + +// TestWrite_SymlinkInsideRootPlantedAfterResolve pins the TOCTOU where a +// local process (the untrusted party is the upstream MCP server itself, not +// the requester) replaces an intermediate directory INSIDE an +// already-Resolve'd target's root with a symlink pointing outside every +// configured root, between the Resolve call and the Write call. Before this +// was fixed (plain os.MkdirAll + os.CreateTemp + os.Rename, which all follow +// symlinks like any other syscall path lookup), this landed the temp file +// and the final write outside the whitelist. Target.Handle is an *os.Root +// scoped to the root directory, opened by Resolve BEFORE this symlink is +// planted — its per-component containment (methods on os.Root follow +// symlinks but refuse to let them reference a location outside the root) +// still refuses to follow a symlink planted at "sub", so the write must fail +// and nothing must appear outside root. See +// TestWrite_RootItselfSwappedForSymlinkAfterResolve below for the more +// severe TOCTOU this feature originally had: the root's OWN path (not a +// directory inside it) being swapped for a symlink. +func TestWrite_SymlinkInsideRootPlantedAfterResolve(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + + target, err := Resolve([]string{root}, filepath.Join(root, "sub", "f.txt"), false) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + defer func() { _ = target.Close() }() + + // Attacker/race window: "sub" did not exist at Resolve time (Write was + // going to MkdirAll it); now plant it as a symlink to outside. + subPath := filepath.Join(root, "sub") + if err := os.Symlink(outside, subPath); err != nil { + t.Skipf("symlinks unsupported: %v", err) + } + + _, err = Write(target, []byte("payload"), 0, false) + if err == nil { + t.Fatal("Write() error = nil, want an error — the planted symlink must not be followed outside root") + } + + // Nothing must have landed outside the root. + entries, err := os.ReadDir(outside) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("write escaped the whitelist via the planted symlink: %v", entries) + } +} + +// TestWrite_RootItselfSwappedForSymlinkAfterResolve pins the TOCTOU a prior +// version of this package had: Write re-opened the root BY PATH +// (os.OpenRoot(target.Root)) inside Write itself, so a symlink planted at +// the root's own path between Resolve returning and Write running caused +// every subsequent os.Root operation to be scoped to whatever the symlink +// pointed at instead of the directory Resolve had actually validated. +// +// With Resolve opening (and identity-checking) the *os.Root handle itself, +// and Write using ONLY that already-open handle, this scenario is closed +// structurally rather than by a check: once the handle is open, a later swap +// of the root's own path cannot move the underlying fd — Write keeps +// operating on the ORIGINAL directory Resolve validated, wherever it now +// lives, regardless of what now occupies that path. This test asserts +// exactly that guarantee: the write succeeds and its bytes land inside the +// ORIGINAL root directory (found at its new, moved-aside location), and +// nothing is ever written into the attacker's directory. +func TestWrite_RootItselfSwappedForSymlinkAfterResolve(t *testing.T) { + base := t.TempDir() + root := filepath.Join(base, "root") + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatal(err) + } + attacker := t.TempDir() + + target, err := Resolve([]string{root}, filepath.Join(root, "f.txt"), false) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if target.Handle == nil { + t.Fatal("Resolve() returned a Target with a nil Handle") + } + defer func() { _ = target.Close() }() + + // Attacker/race window: rename the real root aside and drop a symlink to + // an attacker-controlled directory at the root's former path — AFTER + // Resolve returned (i.e. after Resolve's identity check and os.OpenRoot + // already succeeded against the real directory). + movedAside := filepath.Join(base, "root-moved-aside") + if err := os.Rename(root, movedAside); err != nil { + t.Fatal(err) + } + if err := os.Symlink(attacker, root); err != nil { + t.Skipf("symlinks unsupported: %v", err) + } + + if _, err := Write(target, []byte("payload"), 0, false); err != nil { + t.Fatalf("Write() error = %v, want nil — the already-open handle must keep writing to the original directory regardless of the later swap at its path", err) + } + + // Nothing must have landed in the attacker's directory. + attackerEntries, err := os.ReadDir(attacker) + if err != nil { + t.Fatal(err) + } + if len(attackerEntries) != 0 { + t.Fatalf("write escaped into the attacker's directory via the swapped root path: %v", attackerEntries) + } + + // The write must have landed in the ORIGINAL directory (now living at + // movedAside — os.Root's own doc comment: "If the directory is moved, + // methods on Root reference the original directory in its new location"). + got, err := os.ReadFile(filepath.Join(movedAside, "f.txt")) + if err != nil { + t.Fatalf("expected the write to land in the original (moved) root directory: %v", err) + } + if string(got) != "payload" { + t.Fatalf("file content = %q, want %q", got, "payload") + } +} + +// TestWrite_RootCreatedWhenMissing pins a configured root that does not +// exist yet still working end-to-end — Resolve creates it (os.MkdirAll) +// before opening it, rather than accepting the (Resolve-time-valid) path and +// then failing inside Write with a raw "no such file or directory" once +// os.OpenRoot discovers the directory still doesn't exist. +func TestWrite_RootCreatedWhenMissing(t *testing.T) { + base := t.TempDir() + root := filepath.Join(base, "not-yet-created") + + target, err := Resolve([]string{root}, filepath.Join(root, "out.txt"), false) + if err != nil { + t.Fatalf("Resolve() error = %v, want nil (root not existing yet must not be rejected)", err) + } + defer func() { _ = target.Close() }() + + if _, err := Write(target, []byte("payload"), 0, false); err != nil { + t.Fatalf("Write() error = %v, want nil — Resolve must have created the root directory", err) + } + + fi, err := os.Stat(root) + if err != nil { + t.Fatalf("expected root directory to have been created: %v", err) + } + if !fi.IsDir() { + t.Fatal("root path exists but is not a directory") + } + if fi.Mode().Perm() != 0o700 { + t.Errorf("root dir mode = %v, want 0700", fi.Mode().Perm()) + } + + got, err := os.ReadFile(filepath.Join(root, "out.txt")) + if err != nil { + t.Fatal(err) + } + if string(got) != "payload" { + t.Errorf("file content = %q, want %q", got, "payload") + } +} + +// TestWrite_RootCreatedWhenMissingUnderSymlinkedAncestor pins the same +// not-yet-created-root scenario as TestWrite_RootCreatedWhenMissing, but +// under a symlinked ancestor (the real-world macOS /tmp -> /private/tmp +// case, mirrored by TestResolve_RootMissingUnderSymlinkedAncestor above, +// which only pins Resolve) — the root must be created AND written to +// successfully end-to-end. +func TestWrite_RootCreatedWhenMissingUnderSymlinkedAncestor(t *testing.T) { + real := t.TempDir() + parent := t.TempDir() + link := filepath.Join(parent, "link") + if err := os.Symlink(real, link); err != nil { + t.Skipf("symlinks unsupported: %v", err) + } + + root := filepath.Join(link, "out") // does not exist yet + target, err := Resolve([]string{root}, filepath.Join(root, "f.txt"), false) + if err != nil { + t.Fatalf("Resolve() error = %v, want nil", err) + } + defer func() { _ = target.Close() }() + + if _, err := Write(target, []byte("payload"), 0, false); err != nil { + t.Fatalf("Write() error = %v, want nil", err) + } + + wantReal, err := filepath.EvalSymlinks(real) + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(wantReal, "out", "f.txt")) + if err != nil { + t.Fatalf("expected the file under the real (symlink-resolved) ancestor: %v", err) + } + if string(got) != "payload" { + t.Errorf("file content = %q, want %q", got, "payload") + } + fi, err := os.Stat(filepath.Join(wantReal, "out")) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o700 { + t.Errorf("created root dir mode = %v, want 0700", fi.Mode().Perm()) + } +} + +// TestWrite_ErrorsWrapSentinels pins the sentinel-wrapping contract: a +// caller must be able to tell "the root is unavailable" (ErrRootUnavailable) +// apart from "the write itself failed" (ErrWriteFailed) via errors.Is, +// without the underlying *PathError text being the only signal. +func TestWrite_ErrorsWrapSentinels(t *testing.T) { + root := t.TempDir() + target, err := Resolve([]string{root}, filepath.Join(root, "out.txt"), false) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + // Close the handle early to force every subsequent Write filesystem call + // through it to fail — a closed *os.Root's operations return errors, and + // none of them are ErrExists/ErrTooLarge, so this exercises the generic + // ErrWriteFailed wrapping path deterministically. + if err := target.Handle.Close(); err != nil { + t.Fatal(err) + } + + _, err = Write(target, []byte("payload"), 0, false) + if err == nil { + t.Fatal("Write() error = nil, want an error from the closed handle") + } + if !errors.Is(err, ErrWriteFailed) { + t.Fatalf("Write() error = %v, want errors.Is(err, ErrWriteFailed)", err) + } + if errors.Is(err, ErrExists) || errors.Is(err, ErrTooLarge) { + t.Fatalf("Write() error = %v unexpectedly also matches ErrExists/ErrTooLarge", err) + } + + // Target.Handle == nil is the other ErrRootUnavailable source in Write — + // pin it directly (a hand-built Target that didn't come from Resolve). + _, err = Write(Target{Path: filepath.Join(root, "other.txt"), Root: root, Rel: "other.txt"}, []byte("x"), 0, false) + if !errors.Is(err, ErrRootUnavailable) { + t.Fatalf("Write() error = %v, want errors.Is(err, ErrRootUnavailable) for a nil Handle", err) + } +} diff --git a/internal/runtime/config_hotreload.go b/internal/runtime/config_hotreload.go index dc9c9ae51..9ff876bc5 100644 --- a/internal/runtime/config_hotreload.go +++ b/internal/runtime/config_hotreload.go @@ -95,6 +95,22 @@ func DetectConfigChanges(oldCfg, newCfg *config.Config) *ConfigApplyResult { if oldCfg.ToolResponseLimit != newCfg.ToolResponseLimit { result.ChangedFields = append(result.ChangedFields, "tool_response_limit") } + // save_to_file (Spec 076): this block only affects the human-readable + // changed-fields log below, NOT whether the setting actually takes effect + // live. The whole-config-pointer swap (r.cfg = newCfg, in ApplyConfig) + // makes runtime.Config() return the new values immediately, but that is + // only "hot" for a reader that fetches config through runtime.Config() at + // call time — internal/server.MCPProxyServer.handleCallToolVariant reads + // ToolOutputRoots/ToolOutputMaxBytes via exactly that live-config pattern + // (mirroring the tokenizer-model lookup already used there), not via its + // own p.config field (captured once in NewMCPProxyServer and never + // reassigned by this swap). + if !reflect.DeepEqual(oldCfg.ToolOutputRoots, newCfg.ToolOutputRoots) { + result.ChangedFields = append(result.ChangedFields, "tool_output_roots") + } + if oldCfg.ToolOutputMaxBytes != newCfg.ToolOutputMaxBytes { + result.ChangedFields = append(result.ChangedFields, "tool_output_max_bytes") + } if oldCfg.CallToolTimeout != newCfg.CallToolTimeout { result.ChangedFields = append(result.ChangedFields, "call_tool_timeout") } diff --git a/internal/runtime/config_hotreload_test.go b/internal/runtime/config_hotreload_test.go index 45fb86c70..e7164b4de 100644 --- a/internal/runtime/config_hotreload_test.go +++ b/internal/runtime/config_hotreload_test.go @@ -258,6 +258,28 @@ func TestDetectConfigChanges(t *testing.T) { expectRequiresRestart: false, expectChangedFields: []string{"tools_limit", "tool_response_limit", "call_tool_timeout"}, }, + { + name: "hot-reloadable: save_to_file config changed (Spec 076)", + oldConfig: baseConfig, + newConfig: &config.Config{ + Listen: "127.0.0.1:8080", + DataDir: "/test/data", + APIKey: "test-key", + ToolsLimit: 15, + ToolResponseLimit: 1000, + ToolOutputRoots: []string{"/tmp/mcpproxy-out"}, // Changed + ToolOutputMaxBytes: 10485760, // Changed + CallToolTimeout: config.Duration(60 * time.Second), + Servers: []*config.ServerConfig{}, + TLS: &config.TLSConfig{ + Enabled: false, + }, + }, + expectSuccess: true, + expectAppliedNow: true, + expectRequiresRestart: false, + expectChangedFields: []string{"tool_output_roots", "tool_output_max_bytes"}, + }, } for _, tt := range tests { diff --git a/internal/server/content_forward.go b/internal/server/content_forward.go index 3a4f35add..36a591a13 100644 --- a/internal/server/content_forward.go +++ b/internal/server/content_forward.go @@ -2,11 +2,16 @@ package server import ( "encoding/json" + "errors" "fmt" + "strings" "github.com/mark3labs/mcp-go/mcp" "go.uber.org/zap" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/outputfile" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/server/tokens" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" "github.com/smart-mcp-proxy/mcpproxy-go/internal/truncate" ) @@ -237,3 +242,358 @@ func joinTextParts(parts []string) string { } return string(out) } + +// --- save_to_file (Spec 076) --- +// +// Separates "get the data complete" from "read it into context": when the +// agent sets save_to_file on a call_tool_read/write/destructive call, the +// FULL upstream response is written to a config-whitelisted file instead of +// being (possibly truncated) forwarded inline. + +// saveToFileParams carries the (optional) save_to_file request parameters +// parsed from a call_tool_* invocation. An empty Path means save_to_file was +// not requested for this call. +type saveToFileParams struct { + Path string + Format string // "text" (default) or "json" + Overwrite bool +} + +// saveToFileConfig carries the server-side whitelist configuration needed to +// resolve and write save_to_file targets (internal/config.Config's +// ToolOutputRoots / ToolOutputMaxBytes, threaded through rather than +// importing internal/config here to keep this file's dependency surface +// small and testable). +type saveToFileConfig struct { + Roots []string + MaxBytes int64 +} + +// saveToFileEnvelope is the JSON body returned to the agent in place of the +// (possibly huge) upstream response when save_to_file succeeds. Field names +// are part of the save_to_file contract (Spec 076) — do not rename without +// updating the tool schema description in mcp.go. +type saveToFileEnvelope struct { + SavedTo string `json:"saved_to"` + Bytes int64 `json:"bytes"` + SHA256 string `json:"sha256"` + Format string `json:"format"` + ContentBlocks int `json:"content_blocks"` + NonTextBlocks int `json:"non_text_blocks"` + Preview string `json:"preview"` + TruncatedPreview bool `json:"truncated_preview"` +} + +// previewRuneLimit bounds the "preview" envelope field. +const previewRuneLimit = 1000 + +// maybeSaveToFile implements the save_to_file capability. It MUST be called +// before any ShouldTruncate/Truncate so the file it writes is always the +// complete, untruncated upstream response — never the possibly-truncated one +// forwardContentResult would otherwise produce. +// +// Returns handled=false when save_to_file was not requested (params.Path == +// "") or when the upstream result is itself an error result — in both cases +// the caller must fall through to the normal forwardContentResult truncation +// path completely unchanged (agents must always see upstream errors inline; +// save_to_file never intercepts them). Returns handled=true in every other +// case (success or a save_to_file-specific failure), together with the +// *mcp.CallToolResult the caller should return to the agent AS-IS, skipping +// forwardContentResult entirely. +// +// On a resolve/write failure this returns a tool error result whose text is +// "save_to_file: " — errors never fall back to returning the +// (possibly huge) upstream body inline; the caller explicitly asked for a +// file, so a silent fallback to the old behavior would hide the failure. +// Never logs or echoes file CONTENT — only the resolved path, byte count, +// sha256 and a ≤1000-rune preview ever leave this function. +func maybeSaveToFile(result interface{}, params saveToFileParams, cfg saveToFileConfig) (forwarded *mcp.CallToolResult, handled bool) { + if params.Path == "" { + return nil, false + } + + format := params.Format + if format == "" { + format = "text" + } + if format != "text" && format != "json" { + return saveToFileErrorResult(fmt.Errorf("invalid save_format %q (must be \"text\" or \"json\")", params.Format)), true + } + + ctr, ok := result.(*mcp.CallToolResult) + if !ok || ctr == nil { + // A legacy, non-*mcp.CallToolResult upstream result still honors an + // explicit save_to_file request rather than silently ignoring it + // (silently falling through to forwardContentResult would forward/ + // truncate the response inline as if save_to_file had never been + // set). "text" has nothing well-defined to concatenate for an + // arbitrary interface{}, so only "json" (the whole result, + // JSON-marshaled) is supported on this path. + if format == "text" { + return saveToFileErrorResult(errors.New("unsupported upstream result type for text format; use save_format=\"json\"")), true + } + data, err := json.Marshal(result) + if err != nil { + return saveToFileErrorResult(fmt.Errorf("failed to encode response as json: %w", err)), true + } + return writeSaveToFile(params, cfg, format, data, 0, 0, "") + } + if ctr.IsError { + // Agents must always see errors; never divert an error result to a + // file. Let the existing (unchanged) error-forwarding path run. + return nil, false + } + + totalBlocks, nonTextBlocks := countContentBlocks(ctr) + // The preview — and the format:"text" payload — is always the + // concatenated TextContent blocks only (no placeholders for + // image/audio/unknown blocks). This is what makes non_text_blocks > 0 + // with format:"text" meaningful: those blocks are genuinely absent from + // the saved file, not just replaced with a placeholder string. + textForm := concatSaveableTextBlocks(ctr) + + // format:"text" with content blocks present but none of them + // non-empty text (e.g. an image-only response, or a lone empty-string + // text block) must fail loudly instead of silently writing a 0-byte + // file with a success envelope. A genuinely empty response (zero + // content blocks at all) is unaffected — that is a real empty upstream + // result, not text dropped by this filter. + if format == "text" && textForm == "" && totalBlocks > 0 { + return saveToFileErrorResult(errors.New("response has no non-empty text content; use save_format=\"json\"")), true + } + + var data []byte + if format == "json" { + b, err := json.Marshal(ctr) + if err != nil { + return saveToFileErrorResult(fmt.Errorf("failed to encode response as json: %w", err)), true + } + data = b + } else { + data = []byte(textForm) + } + + return writeSaveToFile(params, cfg, format, data, totalBlocks, nonTextBlocks, textForm) +} + +// writeSaveToFile resolves params.Path against cfg's whitelist, writes data, +// and builds the success envelope. Shared by both the normal +// *mcp.CallToolResult path and the legacy non-*mcp.CallToolResult path +// above; the latter always passes totalBlocks=0, nonTextBlocks=0, +// textForm="" since those concepts don't apply to an arbitrary +// JSON-marshaled interface{}, so the envelope's preview is empty and +// content_blocks/non_text_blocks are 0. +func writeSaveToFile(params saveToFileParams, cfg saveToFileConfig, format string, data []byte, totalBlocks, nonTextBlocks int, textForm string) (*mcp.CallToolResult, bool) { + target, err := outputfile.Resolve(cfg.Roots, params.Path, params.Overwrite) + if err != nil { + return saveToFileErrorResult(err), true + } + // Resolve opens (and identity-checks) target.Handle — an *os.Root — as + // part of validating the path (see outputfile.Resolve); this call owns + // that handle's lifecycle from here on and must close it on every + // return path, success or failure. + defer func() { _ = target.Close() }() + + info, err := outputfile.Write(target, data, cfg.MaxBytes, params.Overwrite) + if err != nil { + return saveToFileErrorResult(err), true + } + + preview, truncatedPreview := previewRunes(textForm, previewRuneLimit) + envJSON, err := json.Marshal(saveToFileEnvelope{ + SavedTo: info.Path, + Bytes: info.Bytes, + SHA256: info.SHA256, + Format: format, + ContentBlocks: totalBlocks, + NonTextBlocks: nonTextBlocks, + Preview: preview, + TruncatedPreview: truncatedPreview, + }) + if err != nil { + return saveToFileErrorResult(fmt.Errorf("failed to encode result envelope: %w", err)), true + } + + return mcp.NewToolResultText(string(envJSON)), true +} + +// saveToFileErrorResult wraps err as the standard "save_to_file: " +// tool error text mandated by the save_to_file contract (Spec 076). +func saveToFileErrorResult(err error) *mcp.CallToolResult { + return mcp.NewToolResultError(fmt.Sprintf("save_to_file: %v", err)) +} + +// recountSaveOrTruncateTokenMetrics corrects tokenMetrics.OutputTokens/ +// TotalTokens to reflect what the agent actually received, for the two +// cases where the raw upstream-body token count computed earlier is wrong: +// a truncated response (wasTruncated) or a save_to_file-diverted response +// (savedToFile) — in both cases the agent saw a shorter forwarded/response +// text, not the full upstream body. tokenMetrics.SavedToFile is always set +// unconditionally (independent of whether a recount is possible below) so a +// caller can tell "this call went through the save path" apart from "this +// call had a coincidentally small body", even when tokenizer is nil or +// errors. +// +// When neither flag is set, this is a no-op beyond setting SavedToFile. +// When a flag is set and tokenizer successfully recounts response, that +// recount wins. When tokenizer is nil, or CountTokensForModel errors, a +// save_to_file response STILL gets corrected to OutputTokens=0/ +// TotalTokens=InputTokens (the agent never saw the un-recountable full +// body either way) — but a truncated-without-save response keeps its +// original (uncorrected, full-body) count, since fixing that pre-existing +// gap is out of scope for this fix pass. +// +// Returns true if tokenMetrics was mutated in a way the caller should +// propagate onto its own copy (toolCallRecord.Metrics = tokenMetrics). +func recountSaveOrTruncateTokenMetrics(tokenMetrics *storage.TokenMetrics, wasTruncated, savedToFile bool, response string, tokenizer tokens.Tokenizer) bool { + if tokenMetrics == nil { + return false + } + tokenMetrics.SavedToFile = savedToFile + if !wasTruncated && !savedToFile { + return false + } + if tokenizer != nil { + if recountedTokens, err := tokenizer.CountTokensForModel(response, tokenMetrics.Model); err == nil { + tokenMetrics.WasTruncated = wasTruncated + tokenMetrics.OutputTokens = recountedTokens + tokenMetrics.TotalTokens = tokenMetrics.InputTokens + tokenMetrics.OutputTokens + return true + } + } + if savedToFile { + tokenMetrics.WasTruncated = wasTruncated + tokenMetrics.OutputTokens = 0 + tokenMetrics.TotalTokens = tokenMetrics.InputTokens + return true + } + return false +} + +// validateSaveToFileArgTypes checks the RAW, un-coerced argument map for the +// save_to_file/save_format/save_overwrite keys before they are parsed with +// mcp-go's lenient request.GetString/GetBool (which silently fall back to +// the zero value on a type mismatch instead of erroring). Without this, a +// caller that accidentally sends save_to_file as a JSON number, or +// save_overwrite as a string, would have the request silently treated as +// "save_to_file not requested" / "overwrite not requested" — the request +// then proceeds and returns the (possibly truncated) inline response, with +// no indication the parameter was ignored. Also rejects an invalid +// save_format enum value here (pre-dispatch), not just a type mismatch — the +// same check maybeSaveToFile makes post-dispatch runs again there as +// defence-in-depth, but catching it here avoids running the (possibly +// destructive) upstream call only to discard its result for a save_format +// typo. Also rejects two shapes that would otherwise be silently +// misinterpreted: a present-but-empty save_to_file string (which downstream +// code treats identically to save_to_file being absent), and save_format or +// save_overwrite supplied without a (non-empty) save_to_file alongside them. +// Returns a non-nil tool-error result if any present argument has the wrong +// type, save_to_file is present but empty, save_format/save_overwrite are +// present without save_to_file, or save_format is present but not "", +// "text", or "json"; nil if every present argument (if any) is well-typed +// and valid. +func validateSaveToFileArgTypes(request mcp.CallToolRequest) *mcp.CallToolResult { + args := request.GetArguments() + saveToFileRequested := false + if v, ok := args["save_to_file"]; ok { + s, isStr := v.(string) + if !isStr { + return saveToFileErrorResult(fmt.Errorf("parameter save_to_file must be a string")) + } + // A present-but-empty save_to_file is exactly the silent-ignore + // failure mode this function exists to prevent: downstream code + // treats "" the same as "key absent" (params.Path == ""), so a + // caller who meant to request a save would instead get the normal + // (possibly truncated) inline response with no indication their + // save_to_file was ignored. + if s == "" { + return saveToFileErrorResult(errors.New("must be a non-empty absolute path")) + } + saveToFileRequested = true + } + if v, ok := args["save_format"]; ok { + s, isStr := v.(string) + if !isStr { + return saveToFileErrorResult(fmt.Errorf("parameter save_format must be a string")) + } + if !saveToFileRequested { + return saveToFileErrorResult(errors.New("save_format/save_overwrite require save_to_file")) + } + if s != "" && s != "text" && s != "json" { + return saveToFileErrorResult(fmt.Errorf("invalid save_format %q (must be \"text\" or \"json\")", s)) + } + } + if v, ok := args["save_overwrite"]; ok { + if _, isBool := v.(bool); !isBool { + return saveToFileErrorResult(fmt.Errorf("parameter save_overwrite must be a bool")) + } + if !saveToFileRequested { + return saveToFileErrorResult(errors.New("save_format/save_overwrite require save_to_file")) + } + } + return nil +} + +// saveToFileTextContent returns a content block's text and true if the +// block is a text block, accepting both the value form (mcp.TextContent, as +// produced by mcp.NewTextContent) and the pointer form (*mcp.TextContent) — +// a bare `c.(mcp.TextContent)` type assertion would mis-classify the +// pointer form as a non-text block and silently drop it from both the +// block count and the saved text, even though it carries real text. +func saveToFileTextContent(c mcp.Content) (string, bool) { + switch tc := c.(type) { + case mcp.TextContent: + return tc.Text, true + case *mcp.TextContent: + if tc == nil { + return "", false + } + return tc.Text, true + default: + return "", false + } +} + +// countContentBlocks reports the total number of content blocks in ctr and +// how many of those are NOT text (image, audio, embedded resource, or any +// other unknown type) — see saveToFileTextContent for what counts as text. +func countContentBlocks(ctr *mcp.CallToolResult) (total, nonText int) { + total = len(ctr.Content) + for _, c := range ctr.Content { + if _, ok := saveToFileTextContent(c); !ok { + nonText++ + } + } + return total, nonText +} + +// concatSaveableTextBlocks concatenates every text block in ctr.Content +// (see saveToFileTextContent), newline-separated. This is the save_to_file +// path's own text extraction — kept separate from the shared +// concatTextBlocks in output_sanitisation.go (which only recognizes the +// value form) so this fix does not change behavior for other callers of +// that function. +func concatSaveableTextBlocks(ctr *mcp.CallToolResult) string { + var b strings.Builder + for _, c := range ctr.Content { + if txt, ok := saveToFileTextContent(c); ok { + // Intentional: gating on b.Len() > 0 drops a leading empty text + // block's separator (["","A","","B"] -> "A\n\nB"); interior/trailing empty blocks still keep theirs. + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(txt) + } + } + return b.String() +} + +// previewRunes returns the first maxRunes runes of s and whether s had more +// than that. +func previewRunes(s string, maxRunes int) (preview string, truncated bool) { + runes := []rune(s) + if len(runes) <= maxRunes { + return s, false + } + return string(runes[:maxRunes]), true +} diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 14a3986c4..267ac8777 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -550,6 +550,16 @@ func buildCallToolVariantTool(variant string) mcp.Tool { mcp.WithString("intent_reason", mcp.Description(reasonDesc), ), + mcp.WithString("save_to_file", + mcp.Description("Absolute path under a configured tool_output_roots entry. Writes the full untruncated response there and returns a short JSON envelope instead. See docs/configuration.md#save-tool-output-to-file."), + ), + mcp.WithString("save_format", + mcp.Enum("text", "json"), + mcp.Description("Only used with save_to_file. 'text' (default) writes the concatenated text content blocks — the same blocks the response-limit truncator would otherwise truncate one at a time — untruncated; 'json' writes the full result including non-text content. See docs/configuration.md#save-tool-output-to-file."), + ), + mcp.WithBoolean("save_overwrite", + mcp.Description("Only used with save_to_file. Default false rejects an existing target file; set true to replace it. See docs/configuration.md#save-tool-output-to-file."), + ), ) return mcp.NewTool(variant, allOpts...) @@ -1558,6 +1568,25 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp. } } + // save_to_file (Spec 076): parsed here (rather than deep in the response + // handling below) so it lives right next to the other request-parameter + // extraction. An empty Path means the feature was not requested for this + // call — see maybeSaveToFile in content_forward.go. + // request.GetString/GetBool are lenient: a wrong-typed argument (e.g. a + // JSON number for save_to_file) silently falls back to the default + // ("" / false) rather than erroring, which would make save_to_file + // silently not happen instead of surfacing the caller's mistake. Check + // the raw argument types explicitly before parsing. + if errResult := validateSaveToFileArgTypes(request); errResult != nil { + return errResult, nil + } + + saveParams := saveToFileParams{ + Path: request.GetString("save_to_file", ""), + Format: request.GetString("save_format", ""), + Overwrite: request.GetBool("save_overwrite", false), + } + // Handle upstream tools via upstream manager (requires server:tool format) if !strings.Contains(toolName, ":") { return mcp.NewToolResultError(fmt.Sprintf("Invalid tool name format: %s (expected server:tool)", toolName)), nil @@ -1898,56 +1927,117 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp. activityResponseBytes := rawByteSize(result) activityRequestBytes := rawByteSize(activityArgs) - forwarded, response, wasTruncated := forwardContentResult(result, p.truncator, p.cacheManager, p.logger, toolName, args) - - // Spec 056: output-schema validation. Strict mode blocks a violating result - // (returns an error); warn mode forwards unchanged after recording a - // policy_decision. No-op when disabled / no schema / error result. - if blockResult := p.applyOutputValidation(ctx, serverName, actualToolName, forwarded); blockResult != nil { - return blockResult, nil + // save_to_file (Spec 076): must run BEFORE forwardContentResult's + // truncation so the file it writes is always the complete, untruncated + // response. It runs AFTER applyOutputSanitisation above (redact/strip/ + // block), so a saved file never contains an unredacted secret, and it + // never intercepts an upstream error result (maybeSaveToFile returns + // handled=false for those — see its doc comment). + // + // tool_output_roots/tool_output_max_bytes are read from the LIVE config + // snapshot, not p.config: p.config is captured once in NewMCPProxyServer + // and never reassigned, so a hot-reloaded change to these settings would + // otherwise silently not take effect until a full restart. This mirrors + // the pattern used for the tokenizer model just above. + saveCfg := saveToFileConfig{ + Roots: p.config.ToolOutputRoots, + MaxBytes: p.config.ToolOutputMaxBytes, } - - // Spec 054 Track B (post-forward): spotlight untrusted text in - // source-identifying delimiters. Lossless and non-cacheable, so it runs - // after truncation. response is refreshed so logs/metrics match agent output. - p.spotlightForwarded(serverName, actualToolName, contentTrust, forwarded) - response = forwardedText(forwarded, response) - - // Track truncation in token metrics - if wasTruncated && tokenMetrics != nil && p.mainServer != nil && p.mainServer.runtime != nil { - tokenizer := p.mainServer.runtime.Tokenizer() - if tokenizer != nil { - truncatedTokens, err := tokenizer.CountTokensForModel(response, tokenMetrics.Model) - if err == nil { - tokenMetrics.WasTruncated = true - tokenMetrics.OutputTokens = truncatedTokens - tokenMetrics.TotalTokens = tokenMetrics.InputTokens + tokenMetrics.OutputTokens - toolCallRecord.Metrics = tokenMetrics + if p.mainServer != nil && p.mainServer.runtime != nil { + if live := p.mainServer.runtime.Config(); live != nil { + saveCfg = saveToFileConfig{ + Roots: live.ToolOutputRoots, + MaxBytes: live.ToolOutputMaxBytes, } } } - // Store successful tool call in history + var forwarded *mcp.CallToolResult + var response string + var wasTruncated bool + var savedToFile bool + if saveResult, saveHandled := maybeSaveToFile(result, saveParams, saveCfg); saveHandled { + // A failed save_to_file (invalid/outside-roots path, write error, ...) + // still already executed the upstream call, so it must NOT skip the + // audit trail below (RecordToolCall/UpdateSessionStats/emitActivity*) — + // it falls through exactly like a successful save. toolCallRecord.Error + // is set here so history reflects the save failure specifically. + savedToFile = true + forwarded = saveResult + response = forwardedText(forwarded, "") + if saveResult.IsError { + toolCallRecord.Error = response + } + // The envelope/error text IS the response — skip forwardContentResult + // (no truncation applies to it) and the output-schema/spotlight passes + // below: its shape intentionally doesn't match the upstream tool's own + // declared output schema, and there is no untrusted upstream text left + // in it worth spotlighting, in both the success and failure case. + } else { + forwarded, response, wasTruncated = forwardContentResult(result, p.truncator, p.cacheManager, p.logger, toolName, args) + + // Spec 056: output-schema validation. Strict mode blocks a violating result + // (returns an error); warn mode forwards unchanged after recording a + // policy_decision. No-op when disabled / no schema / error result. + if blockResult := p.applyOutputValidation(ctx, serverName, actualToolName, forwarded); blockResult != nil { + return blockResult, nil + } + + // Spec 054 Track B (post-forward): spotlight untrusted text in + // source-identifying delimiters. Lossless and non-cacheable, so it runs + // after truncation. response is refreshed so logs/metrics match agent output. + p.spotlightForwarded(serverName, actualToolName, contentTrust, forwarded) + response = forwardedText(forwarded, response) + } + + // Correct tokenMetrics for a truncated or save_to_file-diverted response — + // see recountSaveOrTruncateTokenMetrics's doc comment for why this needs + // its own fallback path (tokenizer nil / erroring) rather than just + // skipping the correction. Factored into a pure, directly unit-testable + // function (save_to_file_test.go: TestRecountSaveOrTruncateTokenMetrics_*) + // rather than inlined here, since this block's own logic — not the + // surrounding request plumbing — is what the fix pass changed. + var tokenizerForRecount tokens.Tokenizer + if p.mainServer != nil && p.mainServer.runtime != nil { + tokenizerForRecount = p.mainServer.runtime.Tokenizer() + } + if recountSaveOrTruncateTokenMetrics(tokenMetrics, wasTruncated, savedToFile, response, tokenizerForRecount) { + toolCallRecord.Metrics = tokenMetrics + } + + // Store tool call in history — a failed save_to_file still recorded an + // upstream call (toolCallRecord.Error is set above for that case), so + // this must run for every path (success, save success, save failure). if err := p.storage.RecordToolCall(toolCallRecord); err != nil { - p.logger.Warn("Failed to record successful tool call", zap.Error(err)) + p.logger.Warn("Failed to record tool call", zap.Error(err)) } - // Update session stats for successful call + // Update session stats for the call if sessionID != "" && tokenMetrics != nil { p.sessionStore.UpdateSessionStats(sessionID, tokenMetrics.TotalTokens) } - // Emit activity completed event for success (with intent metadata for Spec 018) + // Emit activity completed event (with intent metadata for Spec 018). + // A failed save_to_file is reported as status "error" — the upstream call + // executed but the caller did not get the file it asked for. The non-save + // path is unchanged: an upstream tool-level error result (IsError set by + // the upstream) keeps reporting "success" here exactly as before. responseTruncated := tokenMetrics != nil && tokenMetrics.WasTruncated + status := "success" + errText := "" + if savedToFile && forwarded != nil && forwarded.IsError { + status = "error" + errText = response + } var intentMap map[string]interface{} if intent != nil { intentMap = intent.ToMap() } - p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "success", "", duration.Milliseconds(), activityArgs, response, responseTruncated, toolVariant, intentMap, contentTrust, profileSlug, activityRequestBytes, activityResponseBytes) + p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, status, errText, duration.Milliseconds(), activityArgs, response, responseTruncated, toolVariant, intentMap, contentTrust, profileSlug, activityRequestBytes, activityResponseBytes) - // Spec 024: Emit internal tool call event for success + // Spec 024: Emit internal tool call event internalToolName := "call_tool_" + intent.OperationType // e.g., "call_tool_read" - p.emitActivityInternalToolCall(internalToolName, serverName, actualToolName, toolVariant, sessionID, requestID, "success", "", time.Since(internalStartTime).Milliseconds(), activityArgs, result, intentMap, "") + p.emitActivityInternalToolCall(internalToolName, serverName, actualToolName, toolVariant, sessionID, requestID, status, errText, time.Since(internalStartTime).Milliseconds(), activityArgs, result, intentMap, "") return forwarded, nil } diff --git a/internal/server/save_to_file_test.go b/internal/server/save_to_file_test.go new file mode 100644 index 000000000..f8954e89a --- /dev/null +++ b/internal/server/save_to_file_test.go @@ -0,0 +1,776 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "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/contracts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/secret" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/truncate" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream" +) + +// --- (a) regression pin: no save_to_file -> unchanged truncation path --- +// +// maybeSaveToFile must be a pure no-op (handled=false) when save_to_file was +// not requested, so the existing forwardContentResult truncation behavior — +// already pinned by TestForwardContentResult_TruncatesOnlyText et al. above — +// is completely untouched by this feature. +func TestMaybeSaveToFile_NotRequestedFallsThrough(t *testing.T) { + bigText := strings.Repeat("x", 2000) + upstream := &mcp.CallToolResult{ + Content: []mcp.Content{mcp.NewTextContent(bigText)}, + } + + forwarded, handled := maybeSaveToFile(upstream, saveToFileParams{}, saveToFileConfig{}) + assert.False(t, handled) + assert.Nil(t, forwarded) + + // The unmodified path still truncates exactly as before. + truncator := truncate.NewTruncator(500) + result, _, truncated := forwardContentResult(upstream, truncator, nil, nil, "test:tool", nil) + require.NotNil(t, result) + assert.True(t, truncated) +} + +// (b) save_to_file under a whitelisted root: file bytes == full upstream +// text, envelope JSON fields correct, preview <= 1000 runes. +func TestMaybeSaveToFile_TextFormat_WritesFullContent(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "out.txt") + + bigText := strings.Repeat("y", 50000) // well over any tool_response_limit + upstream := &mcp.CallToolResult{ + Content: []mcp.Content{mcp.NewTextContent(bigText)}, + } + + forwarded, handled := maybeSaveToFile(upstream, saveToFileParams{Path: target}, saveToFileConfig{ + Roots: []string{root}, + MaxBytes: 0, + }) + require.True(t, handled) + require.NotNil(t, forwarded) + require.False(t, forwarded.IsError) + + // File on disk must be byte-identical to the full upstream text. + onDisk, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, bigText, string(onDisk)) + + env := decodeEnvelope(t, forwarded) + assert.Equal(t, int64(len(bigText)), env.Bytes) + assert.Equal(t, "text", env.Format) + assert.Equal(t, 1, env.ContentBlocks) + assert.Equal(t, 0, env.NonTextBlocks) + assert.LessOrEqual(t, len([]rune(env.Preview)), 1000) + assert.True(t, env.TruncatedPreview, "50000-rune text must produce a truncated preview") + assert.NotEmpty(t, env.SHA256) + // Never echo file content in the envelope beyond the bounded preview. + assert.Less(t, len(env.Preview), len(bigText)) +} + +// (c) save_format: json -> file parses back to a CallToolResult with all +// blocks (including a non-text block) — for TextContent and ImageContent, +// which round-trip to their original concrete types via mcp-go's +// CallToolResult.UnmarshalJSON/UnmarshalContent dispatch. +// +// Renamed from the original TestMaybeSaveToFile_JSONFormat_RoundTrips: "round +// trips" is only true for content types mcp-go's UnmarshalContent switch maps +// to a concrete struct by their "type" field (text/image/audio/resource_link) +// — it is NOT true for EmbeddedResource, see +// TestMaybeSaveToFile_JSONFormat_EmbeddedResourceDoesNotUnmarshalBackIntoMcpGoTypes +// below. +func TestMaybeSaveToFile_JSONFormat_RoundTripsTextAndImage(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "out.json") + + upstream := &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewTextContent("hello"), + mcp.NewImageContent("aGVsbG8=", "image/png"), + }, + } + + forwarded, handled := maybeSaveToFile(upstream, saveToFileParams{Path: target, Format: "json"}, saveToFileConfig{ + Roots: []string{root}, + }) + require.True(t, handled) + require.NotNil(t, forwarded) + require.False(t, forwarded.IsError) + + raw, err := os.ReadFile(target) + require.NoError(t, err) + + var restored mcp.CallToolResult + require.NoError(t, json.Unmarshal(raw, &restored)) + require.Len(t, restored.Content, 2) + assert.IsType(t, mcp.TextContent{}, restored.Content[0]) + assert.IsType(t, mcp.ImageContent{}, restored.Content[1]) + + env := decodeEnvelope(t, forwarded) + assert.Equal(t, "json", env.Format) + assert.Equal(t, 2, env.ContentBlocks) + assert.Equal(t, 1, env.NonTextBlocks) + // preview is always the text-only form, regardless of save_format. + assert.Equal(t, "hello", env.Preview) + assert.False(t, env.TruncatedPreview) +} + +// (c, EmbeddedResource) save_format:json with an EmbeddedResource block: the FILE on disk +// is a faithful json.Marshal of the original CallToolResult (bytes-correct — +// this is what an agent, or any JSON-aware tool, re-reading the file sees). +// But unmarshaling it back into a fresh mcp.CallToolResult with encoding/json +// (the way the sibling TestMaybeSaveToFile_JSONFormat_RoundTripsTextAndImage +// test does for text/image) does NOT work for EmbeddedResource in mcp-go +// v0.55.0: EmbeddedResource.Resource is the ResourceContents INTERFACE, and +// neither EmbeddedResource nor TextResourceContents/BlobResourceContents +// implement a custom UnmarshalJSON — so when UnmarshalContent's +// "type":"resource" case does a plain `json.Unmarshal(data, &EmbeddedResource{})`, +// encoding/json has no concrete type to target for the interface-typed +// "resource" field and returns +// "json: cannot unmarshal object into Go struct field EmbeddedResource.resource +// of type mcp.ResourceContents" — which fails the ENTIRE CallToolResult +// unmarshal (Content ends up nil), not just that one block. This is the real +// "non-round-tripping" behavior the brief warned about: save_to_file's json +// format still writes complete, correct bytes (verified below), but Go code +// that reads an EmbeddedResource-containing file back into mcp-go's own +// types via encoding/json cannot do so directly — it must decode into +// map[string]interface{} (or a caller-defined shape) instead. +func TestMaybeSaveToFile_JSONFormat_EmbeddedResourceDoesNotUnmarshalBackIntoMcpGoTypes(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "out.json") + + upstream := &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.NewEmbeddedResource(mcp.TextResourceContents{ + URI: "file:///example.txt", + MIMEType: "text/plain", + Text: "embedded body", + }), + }, + } + + forwarded, handled := maybeSaveToFile(upstream, saveToFileParams{Path: target, Format: "json"}, saveToFileConfig{ + Roots: []string{root}, + }) + require.True(t, handled) + require.False(t, forwarded.IsError) + + raw, err := os.ReadFile(target) + require.NoError(t, err) + // The bytes on disk are a complete, correct JSON encoding of the original + // resource — nothing is lost at write time. + assert.Contains(t, string(raw), "embedded body") + assert.Contains(t, string(raw), "file:///example.txt") + + // Unmarshaling those same bytes back into mcp.CallToolResult, however, + // fails outright — this is mcp-go v0.55.0 behavior, not a save_to_file bug. + var restored mcp.CallToolResult + unmarshalErr := json.Unmarshal(raw, &restored) + require.Error(t, unmarshalErr, "mcp-go v0.55.0 cannot unmarshal an EmbeddedResource's interface-typed Resource field back into a concrete type") + assert.Contains(t, unmarshalErr.Error(), "ResourceContents") + + // A generic map decode, by contrast, recovers everything losslessly — + // this is the fallback callers need for EmbeddedResource-bearing files. + var generic map[string]interface{} + require.NoError(t, json.Unmarshal(raw, &generic)) + content, _ := generic["content"].([]interface{}) + require.Len(t, content, 1) + block, _ := content[0].(map[string]interface{}) + resource, _ := block["resource"].(map[string]interface{}) + assert.Equal(t, "embedded body", resource["text"]) +} + +// (d) path outside configured roots -> tool error, no file written. +func TestMaybeSaveToFile_OutsideRoots_ToolErrorNoFile(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + target := filepath.Join(outside, "out.txt") + + upstream := &mcp.CallToolResult{Content: []mcp.Content{mcp.NewTextContent("x")}} + + forwarded, handled := maybeSaveToFile(upstream, saveToFileParams{Path: target}, saveToFileConfig{ + Roots: []string{root}, + }) + require.True(t, handled) + require.NotNil(t, forwarded) + assert.True(t, forwarded.IsError) + assert.Contains(t, toolErrorText(t, forwarded), "save_to_file:") + + _, statErr := os.Stat(target) + assert.True(t, os.IsNotExist(statErr), "no file should have been written outside the whitelist") +} + +// (e) roots empty -> ErrDisabled message surfaced verbatim. +func TestMaybeSaveToFile_RootsEmpty_DisabledMessage(t *testing.T) { + upstream := &mcp.CallToolResult{Content: []mcp.Content{mcp.NewTextContent("x")}} + + forwarded, handled := maybeSaveToFile(upstream, saveToFileParams{Path: "/tmp/whatever/out.txt"}, saveToFileConfig{}) + require.True(t, handled) + require.NotNil(t, forwarded) + assert.True(t, forwarded.IsError) + assert.Contains(t, toolErrorText(t, forwarded), "save_to_file is disabled: configure tool_output_roots") +} + +// (f) upstream isError -> no file, error result forwarded unchanged (handled +// must be false so the caller's existing error path runs untouched). +func TestMaybeSaveToFile_UpstreamIsError_SkipsSave(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "out.txt") + + upstream := mcp.NewToolResultError("upstream failure") + + forwarded, handled := maybeSaveToFile(upstream, saveToFileParams{Path: target}, saveToFileConfig{ + Roots: []string{root}, + }) + assert.False(t, handled) + assert.Nil(t, forwarded) + + _, statErr := os.Stat(target) + assert.True(t, os.IsNotExist(statErr), "no file should be written for an upstream error result") +} + +// Invalid save_format is rejected with a save_to_file: error, no file written. +func TestMaybeSaveToFile_InvalidFormat_ToolError(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "out.txt") + upstream := &mcp.CallToolResult{Content: []mcp.Content{mcp.NewTextContent("x")}} + + forwarded, handled := maybeSaveToFile(upstream, saveToFileParams{Path: target, Format: "xml"}, saveToFileConfig{ + Roots: []string{root}, + }) + require.True(t, handled) + assert.True(t, forwarded.IsError) + assert.Contains(t, toolErrorText(t, forwarded), "save_format") + + _, statErr := os.Stat(target) + assert.True(t, os.IsNotExist(statErr)) +} + +// Existing file without overwrite -> ErrExists surfaced as a tool error. +func TestMaybeSaveToFile_ExistsNoOverwrite_ToolError(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "out.txt") + require.NoError(t, os.WriteFile(target, []byte("old"), 0o644)) + + upstream := &mcp.CallToolResult{Content: []mcp.Content{mcp.NewTextContent("new")}} + forwarded, handled := maybeSaveToFile(upstream, saveToFileParams{Path: target}, saveToFileConfig{ + Roots: []string{root}, + }) + require.True(t, handled) + assert.True(t, forwarded.IsError) + + onDisk, _ := os.ReadFile(target) + assert.Equal(t, "old", string(onDisk), "existing file must be untouched on ErrExists") +} + +// Overwrite=true replaces the existing file's content. +func TestMaybeSaveToFile_Overwrite_ReplacesContent(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "out.txt") + require.NoError(t, os.WriteFile(target, []byte("old"), 0o644)) + + upstream := &mcp.CallToolResult{Content: []mcp.Content{mcp.NewTextContent("new-content")}} + forwarded, handled := maybeSaveToFile(upstream, saveToFileParams{Path: target, Overwrite: true}, saveToFileConfig{ + Roots: []string{root}, + }) + require.True(t, handled) + require.False(t, forwarded.IsError) + + onDisk, _ := os.ReadFile(target) + assert.Equal(t, "new-content", string(onDisk)) +} + +// tool_output_max_bytes is enforced. +func TestMaybeSaveToFile_TooLarge_ToolError(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "out.txt") + upstream := &mcp.CallToolResult{Content: []mcp.Content{mcp.NewTextContent("0123456789")}} + + forwarded, handled := maybeSaveToFile(upstream, saveToFileParams{Path: target}, saveToFileConfig{ + Roots: []string{root}, + MaxBytes: 5, + }) + require.True(t, handled) + assert.True(t, forwarded.IsError) + + _, statErr := os.Stat(target) + assert.True(t, os.IsNotExist(statErr)) +} + +// format:"text" with content blocks present but none of them text (an +// image-only response) must fail with a save_to_file error and write nothing +// — not silently produce a 0-byte file with a success envelope. +func TestMaybeSaveToFile_TextFormat_NoTextBlocks_ToolError(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "out.txt") + + upstream := &mcp.CallToolResult{ + Content: []mcp.Content{mcp.NewImageContent("aGVsbG8=", "image/png")}, + } + + forwarded, handled := maybeSaveToFile(upstream, saveToFileParams{Path: target}, saveToFileConfig{ + Roots: []string{root}, + }) + require.True(t, handled) + require.NotNil(t, forwarded) + assert.True(t, forwarded.IsError) + assert.Contains(t, toolErrorText(t, forwarded), "no non-empty text content") + + _, statErr := os.Stat(target) + assert.True(t, os.IsNotExist(statErr), "no file should be written when format:text has nothing to save") +} + +// A genuinely empty response (zero content blocks at all) is NOT the same +// case as "blocks present but none are text" — format:text still writes (an +// empty file), since there is nothing being silently dropped here. +func TestMaybeSaveToFile_TextFormat_ZeroBlocks_WritesEmptyFile(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "out.txt") + + upstream := &mcp.CallToolResult{Content: []mcp.Content{}} + + forwarded, handled := maybeSaveToFile(upstream, saveToFileParams{Path: target}, saveToFileConfig{ + Roots: []string{root}, + }) + require.True(t, handled) + require.False(t, forwarded.IsError) + + onDisk, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "", string(onDisk)) +} + +// A *mcp.TextContent (pointer) block must be recognized as text — both +// counted correctly and included in the saved text — not mis-classified as a +// non-text block and silently dropped. +func TestMaybeSaveToFile_TextFormat_PointerTextContentIncluded(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "out.txt") + + upstream := &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Type: "text", Text: "pointer-block-text"}}, + } + + forwarded, handled := maybeSaveToFile(upstream, saveToFileParams{Path: target}, saveToFileConfig{ + Roots: []string{root}, + }) + require.True(t, handled) + require.False(t, forwarded.IsError) + + onDisk, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "pointer-block-text", string(onDisk)) + + env := decodeEnvelope(t, forwarded) + assert.Equal(t, 1, env.ContentBlocks) + assert.Equal(t, 0, env.NonTextBlocks, "*mcp.TextContent must be counted as text, not non-text") +} + +// A legacy, non-*mcp.CallToolResult upstream result with save_format:json +// still honors save_to_file (json.Marshal(result) is written) instead of +// silently ignoring the request and falling through to the old +// forward/truncate behavior. +func TestMaybeSaveToFile_LegacyResultType_JSONFormat_Writes(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "out.json") + + // Anything that is not *mcp.CallToolResult exercises the legacy branch. + legacyResult := map[string]interface{}{"status": "ok", "count": 3} + + forwarded, handled := maybeSaveToFile(legacyResult, saveToFileParams{Path: target, Format: "json"}, saveToFileConfig{ + Roots: []string{root}, + }) + require.True(t, handled) + require.NotNil(t, forwarded) + require.False(t, forwarded.IsError) + + raw, err := os.ReadFile(target) + require.NoError(t, err) + + var restored map[string]interface{} + require.NoError(t, json.Unmarshal(raw, &restored)) + assert.Equal(t, "ok", restored["status"]) + assert.Equal(t, float64(3), restored["count"]) +} + +// A legacy, non-*mcp.CallToolResult upstream result with save_format:text (or +// the default) is rejected with an explicit error pointing at +// save_format="json" — "text" has nothing well-defined to concatenate for an +// arbitrary interface{}. +func TestMaybeSaveToFile_LegacyResultType_TextFormat_ToolError(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "out.txt") + + legacyResult := map[string]interface{}{"status": "ok"} + + forwarded, handled := maybeSaveToFile(legacyResult, saveToFileParams{Path: target}, saveToFileConfig{ + Roots: []string{root}, + }) + require.True(t, handled) + require.NotNil(t, forwarded) + assert.True(t, forwarded.IsError) + assert.Contains(t, toolErrorText(t, forwarded), "unsupported upstream result type") + + _, statErr := os.Stat(target) + assert.True(t, os.IsNotExist(statErr)) +} + +// (g) all three call_tool_* variants accept save_to_file/save_format/save_overwrite. +func TestBuildCallToolVariantTool_AdvertisesSaveToFileParams(t *testing.T) { + variants := []string{ + contracts.ToolVariantRead, + contracts.ToolVariantWrite, + contracts.ToolVariantDestructive, + } + for _, variant := range variants { + t.Run(variant, func(t *testing.T) { + tool := buildCallToolVariantTool(variant) + + saveToFileProp, ok := tool.InputSchema.Properties["save_to_file"].(map[string]any) + require.True(t, ok, "schema must advertise 'save_to_file'") + assert.Equal(t, "string", saveToFileProp["type"]) + + saveFormatProp, ok := tool.InputSchema.Properties["save_format"].(map[string]any) + require.True(t, ok, "schema must advertise 'save_format'") + assert.Equal(t, "string", saveFormatProp["type"]) + assert.ElementsMatch(t, []any{"text", "json"}, saveFormatProp["enum"]) + + overwriteProp, ok := tool.InputSchema.Properties["save_overwrite"].(map[string]any) + require.True(t, ok, "schema must advertise 'save_overwrite'") + assert.Equal(t, "boolean", overwriteProp["type"]) + + // None of the new params are required — save_to_file is opt-in. + for _, req := range tool.InputSchema.Required { + assert.NotEqual(t, "save_to_file", req) + assert.NotEqual(t, "save_format", req) + assert.NotEqual(t, "save_overwrite", req) + } + }) + } +} + +// --- (h) argument type validation: request.GetString/GetBool are lenient +// and silently default a wrong-typed argument, which would make save_to_file +// silently not happen instead of surfacing the caller's mistake --- + +func TestValidateSaveToFileArgTypes_AllAbsent(t *testing.T) { + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]any{"name": "server:tool"} + assert.Nil(t, validateSaveToFileArgTypes(request)) +} + +func TestValidateSaveToFileArgTypes_AllWellTyped(t *testing.T) { + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]any{ + "save_to_file": "/tmp/out.txt", + "save_format": "json", + "save_overwrite": true, + } + assert.Nil(t, validateSaveToFileArgTypes(request)) +} + +func TestValidateSaveToFileArgTypes_SaveToFileWrongType(t *testing.T) { + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]any{"save_to_file": 42} + result := validateSaveToFileArgTypes(request) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, toolErrorText(t, result), "save_to_file must be a string") +} + +func TestValidateSaveToFileArgTypes_SaveFormatWrongType(t *testing.T) { + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]any{"save_format": true} + result := validateSaveToFileArgTypes(request) + require.NotNil(t, result) + assert.Contains(t, toolErrorText(t, result), "save_format must be a string") +} + +func TestValidateSaveToFileArgTypes_SaveOverwriteWrongType(t *testing.T) { + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]any{"save_overwrite": "true"} + result := validateSaveToFileArgTypes(request) + require.NotNil(t, result) + assert.Contains(t, toolErrorText(t, result), "save_overwrite must be a bool") +} + +// TestValidateSaveToFileArgTypes_InvalidSaveFormatEnum_RejectedPreDispatch +// pins the fix for the pre-dispatch/post-dispatch gap: previously the +// save_format enum ("text"|"json") was only checked inside maybeSaveToFile, +// which runs AFTER the upstream tool call, so +// call_tool_destructive(..., save_format:"xml") would run the (possibly +// destructive) upstream tool and only then discard the body with an +// "invalid save_format" error. validateSaveToFileArgTypes now rejects the +// bad enum value itself, before dispatch. +func TestValidateSaveToFileArgTypes_InvalidSaveFormatEnum_RejectedPreDispatch(t *testing.T) { + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]any{ + "save_to_file": "/tmp/out.txt", + "save_format": "xml", + } + result := validateSaveToFileArgTypes(request) + require.NotNil(t, result) + assert.True(t, result.IsError) + text := toolErrorText(t, result) + assert.Contains(t, text, `invalid save_format "xml"`) + assert.Contains(t, text, `must be "text" or "json"`) +} + +// TestValidateSaveToFileArgTypes_SaveFormatValidEnums accepts every legal +// save_format value ("text", "json", and "" meaning "unset/default"), each +// alongside a non-empty save_to_file. +func TestValidateSaveToFileArgTypes_SaveFormatValidEnums(t *testing.T) { + for _, format := range []string{"text", "json", ""} { + t.Run(format, func(t *testing.T) { + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]any{ + "save_to_file": "/tmp/out.txt", + "save_format": format, + } + assert.Nil(t, validateSaveToFileArgTypes(request)) + }) + } +} + +// TestValidateSaveToFileArgTypes_SaveToFileEmptyString_Rejected pins the fix +// for the other pre-existing gap: a present-but-EMPTY save_to_file was +// silently ignored downstream (params.Path == "" is indistinguishable from +// "key absent"), which is exactly the failure mode this function's doc +// comment says it exists to prevent for other argument shapes. +func TestValidateSaveToFileArgTypes_SaveToFileEmptyString_Rejected(t *testing.T) { + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]any{"save_to_file": ""} + result := validateSaveToFileArgTypes(request) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Equal(t, "save_to_file: must be a non-empty absolute path", toolErrorText(t, result)) +} + +// TestValidateSaveToFileArgTypes_SaveFormatWithoutSaveToFile_Rejected pins +// that save_format supplied without a (non-empty) save_to_file is rejected +// rather than silently ignored. +func TestValidateSaveToFileArgTypes_SaveFormatWithoutSaveToFile_Rejected(t *testing.T) { + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]any{"save_format": "json"} + result := validateSaveToFileArgTypes(request) + require.NotNil(t, result) + assert.Equal(t, "save_to_file: save_format/save_overwrite require save_to_file", toolErrorText(t, result)) +} + +// TestValidateSaveToFileArgTypes_SaveOverwriteWithoutSaveToFile_Rejected +// mirrors the save_format case above for save_overwrite. +func TestValidateSaveToFileArgTypes_SaveOverwriteWithoutSaveToFile_Rejected(t *testing.T) { + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]any{"save_overwrite": true} + result := validateSaveToFileArgTypes(request) + require.NotNil(t, result) + assert.Equal(t, "save_to_file: save_format/save_overwrite require save_to_file", toolErrorText(t, result)) +} + +// TestValidateSaveToFileArgTypes_SaveToFileEmptyWithSaveFormat_EmptyPathErrorWins +// checks precedence when both are wrong: the empty-path error fires before +// the "requires save_to_file" error, since save_to_file is technically +// present (just invalid) rather than absent. +func TestValidateSaveToFileArgTypes_SaveToFileEmptyWithSaveFormat_EmptyPathErrorWins(t *testing.T) { + request := mcp.CallToolRequest{} + request.Params.Arguments = map[string]any{ + "save_to_file": "", + "save_format": "json", + } + result := validateSaveToFileArgTypes(request) + require.NotNil(t, result) + assert.Equal(t, "save_to_file: must be a non-empty absolute path", toolErrorText(t, result)) +} + +// TestHandleCallToolVariant_SaveToFileInvalidFormatEnum_RejectedBeforeUpstream +// drives the real handler (not just the pure helper above) to prove the +// enum check runs BEFORE upstream dispatch, exactly like the existing +// TestHandleCallToolVariant_SaveToFileWrongType_RejectedBeforeUpstream test +// below does for a type mismatch — the mock proxy has no real upstream +// servers configured, so any error text other than the save_format one +// would mean the (possibly destructive) upstream call was attempted first. +func TestHandleCallToolVariant_SaveToFileInvalidFormatEnum_RejectedBeforeUpstream(t *testing.T) { + mockProxy := &MCPProxyServer{ + upstreamManager: upstream.NewManager(zap.NewNop(), config.DefaultConfig(), nil, secret.NewResolver(), nil), + logger: zap.NewNop(), + config: &config.Config{}, + } + ctx := context.Background() + + request := mcp.CallToolRequest{} + request.Params.Name = contracts.ToolVariantDestructive + request.Params.Arguments = map[string]any{ + "name": "non-existent-server:some_tool", + "args": map[string]any{}, + "save_to_file": "/tmp/should-not-be-written.txt", + "save_format": "xml", + } + + result, err := mockProxy.handleCallToolVariant(ctx, request, contracts.ToolVariantDestructive) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.IsError) + text := toolErrorText(t, result) + assert.Contains(t, text, `invalid save_format "xml"`) + _, statErr := os.Stat("/tmp/should-not-be-written.txt") + assert.True(t, os.IsNotExist(statErr), "upstream must never have been dispatched, so nothing was ever written") +} + +// TestHandleCallToolVariant_SaveToFileWrongType_RejectedBeforeUpstream drives +// the real handler (not just the pure helper above) to prove the type check +// actually runs, and runs BEFORE the upstream dispatch — the mock proxy below +// has no real upstream servers configured at all, so any error text other +// than "save_to_file: parameter ... must be a ..." would mean the request +// either silently ignored the bad argument and fell through to (failed) +// upstream dispatch, or failed for an unrelated reason. Mirrors the +// no-live-upstream-needed mock pattern already used by +// TestHandleCallToolVariantAcceptsArgsObject in mcp_call_tool_args_test.go. +func TestHandleCallToolVariant_SaveToFileWrongType_RejectedBeforeUpstream(t *testing.T) { + mockProxy := &MCPProxyServer{ + upstreamManager: upstream.NewManager(zap.NewNop(), config.DefaultConfig(), nil, secret.NewResolver(), nil), + logger: zap.NewNop(), + config: &config.Config{}, + } + ctx := context.Background() + + request := mcp.CallToolRequest{} + request.Params.Name = contracts.ToolVariantRead + request.Params.Arguments = map[string]any{ + "name": "non-existent-server:some_tool", + "args": map[string]any{}, + "save_to_file": 12345, // wrong type: must be a string + } + + result, err := mockProxy.handleCallToolVariant(ctx, request, contracts.ToolVariantRead) + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.IsError) + assert.Contains(t, toolErrorText(t, result), "save_to_file: parameter save_to_file must be a string") +} + +// --- (i) recountSaveOrTruncateTokenMetrics: this fix pass's own new token- +// metrics-correction logic, factored out of handleCallToolVariant's post- +// forward block into a +// pure function so it can be pinned directly (see mcp.go's call site for +// where this replaces the formerly-inlined block; route (b) from the fix +// pass brief, chosen because exercising this through handleCallToolVariant +// itself would need a full live-upstream test harness that does not exist +// in this package today — see the final report for what that leaves +// untested at the handleCallToolVariant integration level: the live-config +// hot-reload read and the RecordToolCall fall-through wiring around this +// call site are structurally reviewed but not pinned by a new test here). --- + +// fakeTokenizer is a minimal tokens.Tokenizer for pinning +// recountSaveOrTruncateTokenMetrics without pulling in the real +// tiktoken-backed DefaultTokenizer (which needs a network-fetched encoding +// cache, unavailable in this offline sandbox — see the pre-existing +// internal/server/tokens package failures noted in the workstate). +type fakeTokenizer struct { + tokens int + err error +} + +func (f fakeTokenizer) CountTokens(string) (int, error) { return f.tokens, f.err } +func (f fakeTokenizer) CountTokensForModel(string, string) (int, error) { return f.tokens, f.err } +func (f fakeTokenizer) CountTokensForEncoding(string, string) (int, error) { return f.tokens, f.err } +func (f fakeTokenizer) CountTokensInJSON(interface{}) (int, error) { return f.tokens, f.err } +func (f fakeTokenizer) CountTokensInJSONForModel(interface{}, string) (int, error) { + return f.tokens, f.err +} + +func TestRecountSaveOrTruncateTokenMetrics_NilMetrics_NoPanic(t *testing.T) { + mutated := recountSaveOrTruncateTokenMetrics(nil, true, true, "x", fakeTokenizer{tokens: 5}) + assert.False(t, mutated) +} + +func TestRecountSaveOrTruncateTokenMetrics_NeitherFlag_OnlySetsSavedToFileFalse(t *testing.T) { + tm := &storage.TokenMetrics{InputTokens: 10, OutputTokens: 9000, TotalTokens: 9010} + mutated := recountSaveOrTruncateTokenMetrics(tm, false, false, "unused", fakeTokenizer{tokens: 5}) + assert.False(t, mutated) + assert.False(t, tm.SavedToFile) + // Untouched — this function must not correct the plain-truncation-free, + // plain-save-free case at all. + assert.Equal(t, 9000, tm.OutputTokens) +} + +func TestRecountSaveOrTruncateTokenMetrics_SavedToFile_TokenizerSucceeds(t *testing.T) { + tm := &storage.TokenMetrics{InputTokens: 10, OutputTokens: 90000, TotalTokens: 90010, Model: "gpt-4"} + mutated := recountSaveOrTruncateTokenMetrics(tm, false, true, "the short envelope", fakeTokenizer{tokens: 7}) + require.True(t, mutated) + assert.True(t, tm.SavedToFile) + assert.False(t, tm.WasTruncated) + assert.Equal(t, 7, tm.OutputTokens, "must be recounted from the envelope text, not left at the full upstream-body count") + assert.Equal(t, 17, tm.TotalTokens) +} + +func TestRecountSaveOrTruncateTokenMetrics_SavedToFile_NilTokenizer_ZerosOutputTokens(t *testing.T) { + tm := &storage.TokenMetrics{InputTokens: 10, OutputTokens: 90000, TotalTokens: 90010} + mutated := recountSaveOrTruncateTokenMetrics(tm, false, true, "the short envelope", nil) + require.True(t, mutated) + assert.True(t, tm.SavedToFile) + assert.Equal(t, 0, tm.OutputTokens, "no tokenizer available — must still correct away from the full upstream-body count, not leave it") + assert.Equal(t, tm.InputTokens, tm.TotalTokens) +} + +func TestRecountSaveOrTruncateTokenMetrics_SavedToFile_TokenizerErrors_ZerosOutputTokens(t *testing.T) { + tm := &storage.TokenMetrics{InputTokens: 10, OutputTokens: 90000, TotalTokens: 90010} + mutated := recountSaveOrTruncateTokenMetrics(tm, false, true, "x", fakeTokenizer{err: errors.New("boom")}) + require.True(t, mutated) + assert.Equal(t, 0, tm.OutputTokens) + assert.Equal(t, tm.InputTokens, tm.TotalTokens) +} + +func TestRecountSaveOrTruncateTokenMetrics_TruncatedWithoutSave_TokenizerSucceeds(t *testing.T) { + tm := &storage.TokenMetrics{InputTokens: 10, OutputTokens: 90000, TotalTokens: 90010, Model: "gpt-4"} + mutated := recountSaveOrTruncateTokenMetrics(tm, true, false, "truncated text", fakeTokenizer{tokens: 3}) + require.True(t, mutated) + assert.False(t, tm.SavedToFile) + assert.True(t, tm.WasTruncated) + assert.Equal(t, 3, tm.OutputTokens) + assert.Equal(t, 13, tm.TotalTokens) +} + +func TestRecountSaveOrTruncateTokenMetrics_TruncatedWithoutSave_NilTokenizer_LeavesCountUnchanged(t *testing.T) { + // Pre-existing, already-shipped behavior this fix pass does not change: + // a truncated (but not saved) response with no tokenizer available keeps + // its original full-body count rather than being zeroed — only the + // save_to_file case gets the zero-fallback correction. + tm := &storage.TokenMetrics{InputTokens: 10, OutputTokens: 90000, TotalTokens: 90010} + mutated := recountSaveOrTruncateTokenMetrics(tm, true, false, "truncated text", nil) + assert.False(t, mutated) + assert.Equal(t, 90000, tm.OutputTokens) + assert.Equal(t, 90010, tm.TotalTokens) +} + +// --- test helpers --- + +func decodeEnvelope(t *testing.T, forwarded *mcp.CallToolResult) saveToFileEnvelope { + t.Helper() + require.Len(t, forwarded.Content, 1) + tc, ok := forwarded.Content[0].(mcp.TextContent) + require.True(t, ok, "envelope must be a single TextContent block") + var env saveToFileEnvelope + require.NoError(t, json.Unmarshal([]byte(tc.Text), &env)) + return env +} + +func toolErrorText(t *testing.T, result *mcp.CallToolResult) string { + t.Helper() + require.Len(t, result.Content, 1) + tc, ok := result.Content[0].(mcp.TextContent) + require.True(t, ok) + return tc.Text +} diff --git a/internal/storage/server_identity.go b/internal/storage/server_identity.go index 8cda16b5e..c5caee0d8 100644 --- a/internal/storage/server_identity.go +++ b/internal/storage/server_identity.go @@ -54,6 +54,7 @@ type TokenMetrics struct { EstimatedCost float64 `json:"estimated_cost,omitempty"` // Optional cost estimate TruncatedTokens int `json:"truncated_tokens,omitempty"` // Tokens removed by truncation WasTruncated bool `json:"was_truncated"` // Whether response was truncated + SavedToFile bool `json:"saved_to_file,omitempty"` // Whether save_to_file diverted the response to a file (OutputTokens then counts the envelope/error, not the full upstream body) } // ToolCallRecord represents a tool call with server context diff --git a/oas/docs.go b/oas/docs.go index ddc085d2a..9bc55875f 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -6,7 +6,7 @@ import "github.com/swaggo/swag/v2" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, - "components": {"schemas":{"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_max_size_mb":{"description":"Max total activity-log size in MB before pruning oldest (default: 256, 0=disabled)","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"allow_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"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_result_size_chars":{"description":"Advertised on every tool as ` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; raises Claude Code's inline-response ceiling from 50k to up to 500k chars. Set to 0 to disable.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of 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_discovery_interval":{"type":"string"},"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.ObservabilityConfig":{"description":"Observability settings (Spec 069): usage aggregate cache/persistence cadence.","properties":{"usage_cache_ttl":{"description":"UsageCacheTTL bounds the freshness of the usage endpoint's read cache for\nwide windows (FR-005). Default 5s.","type":"string"},"usage_persist_interval":{"description":"UsagePersistInterval is how often the actor-owned usage aggregate snapshot\nis flushed to storage. Default 30s.","type":"string"}},"type":"object"},"config.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.ProfileConfig":{"properties":{"name":{"description":"URL slug, validated","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"auto_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_fetch_package_source":{"description":"ScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"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":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"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.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostic":{"description":"Spec 044 — structured diagnostic error and stable error code. Both\nare populated when the server is in a failed state and the error\nhas been classified by internal/diagnostics. Healthy servers omit\nthese fields.","properties":{"cause":{"type":"string"},"code":{"type":"string"},"detected_at":{"type":"string"},"docs_url":{"type":"string"},"fix_steps":{"items":{"$ref":"#/components/schemas/contracts.DiagnosticFixStep"},"type":"array","uniqueItems":false},"severity":{"type":"string"},"user_message":{"type":"string"}},"type":"object"},"contracts.DiagnosticFixStep":{"properties":{"command":{"type":"string"},"destructive":{"type":"boolean"},"fixer_key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.EditRegistrySourceRequest":{"properties":{"name":{"description":"new display name","type":"string"},"servers_url":{"description":"explicit servers-collection URL","type":"string"},"url":{"description":"new base/servers https URL","type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.FindingCounts":{"properties":{"dangerous":{"description":"Tool poisoning, active prompt injection","type":"integer"},"info":{"description":"Low-severity CVEs, informational","type":"integer"},"total":{"type":"integer"},"warning":{"description":"Rug pull, supply chain CVEs with exploits","type":"integer"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object","type":"object"},"config_path":{"description":"Path to config file","type":"string"}},"type":"object"},"contracts.GetRegistriesResponse":{"properties":{"registries":{"items":{"$ref":"#/components/schemas/contracts.Registry"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerLogsResponse":{"properties":{"count":{"type":"integer"},"logs":{"items":{"$ref":"#/components/schemas/contracts.LogEntry"},"type":"array","uniqueItems":false},"server_name":{"type":"string"}},"type":"object"},"contracts.GetServerToolCallsResponse":{"properties":{"server_name":{"type":"string"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerToolsResponse":{"properties":{"count":{"type":"integer"},"server_name":{"type":"string"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GetServersResponse":{"properties":{"servers":{"items":{"$ref":"#/components/schemas/contracts.Server"},"type":"array","uniqueItems":false},"stats":{"$ref":"#/components/schemas/contracts.ServerStats"}},"type":"object"},"contracts.GetSessionDetailResponse":{"properties":{"session":{"$ref":"#/components/schemas/contracts.MCPSession"}},"type":"object"},"contracts.GetSessionsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"sessions":{"items":{"$ref":"#/components/schemas/contracts.MCPSession"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetToolCallDetailResponse":{"properties":{"tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"}},"type":"object"},"contracts.GetToolCallsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GlobalToolsResponse":{"properties":{"failed_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"partial":{"type":"boolean"},"stats":{"$ref":"#/components/schemas/contracts.GlobalToolsStats"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GlobalToolsStats":{"properties":{"disabled":{"type":"integer"},"enabled":{"type":"integer"},"pending_approval":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.HealthStatus":{"description":"Unified health status calculated by the backend","properties":{"action":{"description":"Action is the suggested fix action: \"login\", \"restart\", \"enable\", \"approve\", \"view_logs\", \"set_secret\", \"configure\", or \"\" (none)","type":"string"},"admin_state":{"description":"AdminState indicates the admin state: \"enabled\", \"disabled\", or \"quarantined\"","type":"string"},"detail":{"description":"Detail is an optional longer explanation of the status","type":"string"},"level":{"description":"Level indicates the health level: \"healthy\", \"degraded\", or \"unhealthy\"","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"}},"type":"object"},"contracts.InfoEndpoints":{"description":"Available API endpoints","properties":{"http":{"description":"HTTP endpoint address (e.g., \"127.0.0.1:8080\")","type":"string"},"socket":{"description":"Unix socket path (empty if disabled)","type":"string"}},"type":"object"},"contracts.InfoResponse":{"properties":{"endpoints":{"$ref":"#/components/schemas/contracts.InfoEndpoints"},"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"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary scan pass. \"degraded\" status means\nScannersFailed \u003e 0, so the risk score reflects an incomplete scan and a\nlow score should not be read as a trustworthy all-clear (MCP-2401).","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"degraded\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"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"},"source_registry_id":{"description":"MCP-901 — registry provenance of an upstream that was added from a\nregistry. SourceRegistryID names the source registry (empty for\nmanually-configured servers); SourceRegistryProvenance is the trust tag\nrecorded at add time (\"official/trusted\" or \"custom/unverified\"). Both\nare projected from config.ServerConfig so the approval/quarantine view\ncan render an \"added from \u003cregistry\u003e · unverified\" origin badge. Optional\nand omitted when empty — clients that pre-date this treat them as absent.","type":"string"},"source_registry_provenance":{"type":"string"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"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.UsageAggregateResponse":{"properties":{"freshness_ms":{"description":"age of the underlying snapshot in ms","type":"integer"},"generated_at":{"type":"string"},"other":{"$ref":"#/components/schemas/contracts.UsageOtherBucket"},"timeline":{"items":{"$ref":"#/components/schemas/contracts.UsageTimeBucket"},"type":"array","uniqueItems":false},"token_source":{"description":"\"bytes\" (size-based proxy, FR-006)","type":"string"},"tokens_saved":{"description":"echoed from ServerTokenMetrics (FR-007)","type":"integer"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"window":{"type":"string"}},"type":"object"},"contracts.UsageOtherBucket":{"description":"present only when the list was truncated to top-N","properties":{"calls":{"type":"integer"},"tools_folded":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageTimeBucket":{"properties":{"calls":{"type":"integer"},"errors":{"type":"integer"},"start":{"type":"string"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageToolStat":{"properties":{"avg_req_bytes":{"description":"null when no sized request calls","type":"integer"},"avg_resp_bytes":{"description":"null when sized_calls == 0 (only legacy 0-byte calls)","type":"integer"},"blocked":{"type":"integer"},"calls":{"type":"integer"},"error_rate":{"type":"number"},"errors":{"type":"integer"},"last_used":{"type":"string"},"p50_ms":{"type":"integer"},"p95_ms":{"type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"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 a server name → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys are matched against\neither the raw source name (OriginalName) or the sanitized name shown\nin the preview (Server.Name); these differ for names that need\nsanitizing (e.g. \"Figma Desktop\" → \"Figma_Desktop\"). Keys not present\nin the imported set are ignored.","type":"object"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportRequest":{"properties":{"content":{"description":"Raw JSON or TOML content","type":"string"},"format":{"description":"Optional format hint","type":"string"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportResponse":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/configimport.FailedServer"},"type":"array","uniqueItems":false},"format":{"type":"string"},"format_name":{"type":"string"},"imported":{"items":{"$ref":"#/components/schemas/httpapi.ImportedServerResponse"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/configimport.SkippedServer"},"type":"array","uniqueItems":false},"summary":{"$ref":"#/components/schemas/configimport.ImportSummary"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportedServerResponse":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (image,\nnetwork_mode, extra_args, working_dir, enabled). A nil pointer\nmeans \"do not touch isolation config\"; an empty-but-present\nobject on PATCH intentionally clears the overrides.","properties":{"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.OnboardingMarkRequest":{"properties":{"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","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"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned upstream servers (MCP-2769). It is OFF by\ndefault and deliberately kept out of the AllowedSystemVars default list:\nproxy URLs frequently carry credentials (http://user:pass@proxy), so\nforwarding them to every stdio upstream is a credential-leak risk. When\nenabled, values are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"},"telemetry.FeedbackContext":{"properties":{"arch":{"type":"string"},"connected_server_count":{"type":"integer"},"edition":{"type":"string"},"os":{"type":"string"},"routing_mode":{"type":"string"},"server_count":{"type":"integer"},"version":{"type":"string"}},"type":"object"},"telemetry.FeedbackRequest":{"properties":{"category":{"description":"bug, feature, other","type":"string"},"context":{"$ref":"#/components/schemas/telemetry.FeedbackContext"},"email":{"type":"string"},"message":{"type":"string"}},"type":"object"},"telemetry.FeedbackResponse":{"properties":{"error":{"type":"string"},"issue_url":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"description":"API key authentication via query parameter. Use ?apikey=your-key","in":"query","name":"apikey","type":"apiKey"}}}, + "components": {"schemas":{"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_max_size_mb":{"description":"Max total activity-log size in MB before pruning oldest (default: 256, 0=disabled)","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"allow_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"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_result_size_chars":{"description":"Advertised on every tool as ` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; raises Claude Code's inline-response ceiling from 50k to up to 500k chars. Set to 0 to disable.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of 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_discovery_interval":{"type":"string"},"tool_output_max_bytes":{"description":"ToolOutputMaxBytes caps the size of a single save_to_file write (Spec 076). 0 means \"use the built-in default\" (50 MiB); negative values are a config error.","type":"integer"},"tool_output_roots":{"description":"ToolOutputRoots is the whitelist of absolute directory prefixes the save_to_file parameter on call_tool_read/write/destructive is allowed to write under (Spec 076). Empty (the default) disables the feature.","items":{"type":"string"},"type":"array"},"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.ObservabilityConfig":{"description":"Observability settings (Spec 069): usage aggregate cache/persistence cadence.","properties":{"usage_cache_ttl":{"description":"UsageCacheTTL bounds the freshness of the usage endpoint's read cache for\nwide windows (FR-005). Default 5s.","type":"string"},"usage_persist_interval":{"description":"UsagePersistInterval is how often the actor-owned usage aggregate snapshot\nis flushed to storage. Default 30s.","type":"string"}},"type":"object"},"config.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.ProfileConfig":{"properties":{"name":{"description":"URL slug, validated","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"auto_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_fetch_package_source":{"description":"ScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"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":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"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.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostic":{"description":"Spec 044 — structured diagnostic error and stable error code. Both\nare populated when the server is in a failed state and the error\nhas been classified by internal/diagnostics. Healthy servers omit\nthese fields.","properties":{"cause":{"type":"string"},"code":{"type":"string"},"detected_at":{"type":"string"},"docs_url":{"type":"string"},"fix_steps":{"items":{"$ref":"#/components/schemas/contracts.DiagnosticFixStep"},"type":"array","uniqueItems":false},"severity":{"type":"string"},"user_message":{"type":"string"}},"type":"object"},"contracts.DiagnosticFixStep":{"properties":{"command":{"type":"string"},"destructive":{"type":"boolean"},"fixer_key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.EditRegistrySourceRequest":{"properties":{"name":{"description":"new display name","type":"string"},"servers_url":{"description":"explicit servers-collection URL","type":"string"},"url":{"description":"new base/servers https URL","type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.FindingCounts":{"properties":{"dangerous":{"description":"Tool poisoning, active prompt injection","type":"integer"},"info":{"description":"Low-severity CVEs, informational","type":"integer"},"total":{"type":"integer"},"warning":{"description":"Rug pull, supply chain CVEs with exploits","type":"integer"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object","type":"object"},"config_path":{"description":"Path to config file","type":"string"}},"type":"object"},"contracts.GetRegistriesResponse":{"properties":{"registries":{"items":{"$ref":"#/components/schemas/contracts.Registry"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerLogsResponse":{"properties":{"count":{"type":"integer"},"logs":{"items":{"$ref":"#/components/schemas/contracts.LogEntry"},"type":"array","uniqueItems":false},"server_name":{"type":"string"}},"type":"object"},"contracts.GetServerToolCallsResponse":{"properties":{"server_name":{"type":"string"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerToolsResponse":{"properties":{"count":{"type":"integer"},"server_name":{"type":"string"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GetServersResponse":{"properties":{"servers":{"items":{"$ref":"#/components/schemas/contracts.Server"},"type":"array","uniqueItems":false},"stats":{"$ref":"#/components/schemas/contracts.ServerStats"}},"type":"object"},"contracts.GetSessionDetailResponse":{"properties":{"session":{"$ref":"#/components/schemas/contracts.MCPSession"}},"type":"object"},"contracts.GetSessionsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"sessions":{"items":{"$ref":"#/components/schemas/contracts.MCPSession"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetToolCallDetailResponse":{"properties":{"tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"}},"type":"object"},"contracts.GetToolCallsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GlobalToolsResponse":{"properties":{"failed_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"partial":{"type":"boolean"},"stats":{"$ref":"#/components/schemas/contracts.GlobalToolsStats"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GlobalToolsStats":{"properties":{"disabled":{"type":"integer"},"enabled":{"type":"integer"},"pending_approval":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.HealthStatus":{"description":"Unified health status calculated by the backend","properties":{"action":{"description":"Action is the suggested fix action: \"login\", \"restart\", \"enable\", \"approve\", \"view_logs\", \"set_secret\", \"configure\", or \"\" (none)","type":"string"},"admin_state":{"description":"AdminState indicates the admin state: \"enabled\", \"disabled\", or \"quarantined\"","type":"string"},"detail":{"description":"Detail is an optional longer explanation of the status","type":"string"},"level":{"description":"Level indicates the health level: \"healthy\", \"degraded\", or \"unhealthy\"","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"}},"type":"object"},"contracts.InfoEndpoints":{"description":"Available API endpoints","properties":{"http":{"description":"HTTP endpoint address (e.g., \"127.0.0.1:8080\")","type":"string"},"socket":{"description":"Unix socket path (empty if disabled)","type":"string"}},"type":"object"},"contracts.InfoResponse":{"properties":{"endpoints":{"$ref":"#/components/schemas/contracts.InfoEndpoints"},"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"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary scan pass. \"degraded\" status means\nScannersFailed \u003e 0, so the risk score reflects an incomplete scan and a\nlow score should not be read as a trustworthy all-clear (MCP-2401).","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"degraded\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"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"},"source_registry_id":{"description":"MCP-901 — registry provenance of an upstream that was added from a\nregistry. SourceRegistryID names the source registry (empty for\nmanually-configured servers); SourceRegistryProvenance is the trust tag\nrecorded at add time (\"official/trusted\" or \"custom/unverified\"). Both\nare projected from config.ServerConfig so the approval/quarantine view\ncan render an \"added from \u003cregistry\u003e · unverified\" origin badge. Optional\nand omitted when empty — clients that pre-date this treat them as absent.","type":"string"},"source_registry_provenance":{"type":"string"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"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.UsageAggregateResponse":{"properties":{"freshness_ms":{"description":"age of the underlying snapshot in ms","type":"integer"},"generated_at":{"type":"string"},"other":{"$ref":"#/components/schemas/contracts.UsageOtherBucket"},"timeline":{"items":{"$ref":"#/components/schemas/contracts.UsageTimeBucket"},"type":"array","uniqueItems":false},"token_source":{"description":"\"bytes\" (size-based proxy, FR-006)","type":"string"},"tokens_saved":{"description":"echoed from ServerTokenMetrics (FR-007)","type":"integer"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"window":{"type":"string"}},"type":"object"},"contracts.UsageOtherBucket":{"description":"present only when the list was truncated to top-N","properties":{"calls":{"type":"integer"},"tools_folded":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageTimeBucket":{"properties":{"calls":{"type":"integer"},"errors":{"type":"integer"},"start":{"type":"string"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageToolStat":{"properties":{"avg_req_bytes":{"description":"null when no sized request calls","type":"integer"},"avg_resp_bytes":{"description":"null when sized_calls == 0 (only legacy 0-byte calls)","type":"integer"},"blocked":{"type":"integer"},"calls":{"type":"integer"},"error_rate":{"type":"number"},"errors":{"type":"integer"},"last_used":{"type":"string"},"p50_ms":{"type":"integer"},"p95_ms":{"type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"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 a server name → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys are matched against\neither the raw source name (OriginalName) or the sanitized name shown\nin the preview (Server.Name); these differ for names that need\nsanitizing (e.g. \"Figma Desktop\" → \"Figma_Desktop\"). Keys not present\nin the imported set are ignored.","type":"object"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportRequest":{"properties":{"content":{"description":"Raw JSON or TOML content","type":"string"},"format":{"description":"Optional format hint","type":"string"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportResponse":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/configimport.FailedServer"},"type":"array","uniqueItems":false},"format":{"type":"string"},"format_name":{"type":"string"},"imported":{"items":{"$ref":"#/components/schemas/httpapi.ImportedServerResponse"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/configimport.SkippedServer"},"type":"array","uniqueItems":false},"summary":{"$ref":"#/components/schemas/configimport.ImportSummary"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportedServerResponse":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (image,\nnetwork_mode, extra_args, working_dir, enabled). A nil pointer\nmeans \"do not touch isolation config\"; an empty-but-present\nobject on PATCH intentionally clears the overrides.","properties":{"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.OnboardingMarkRequest":{"properties":{"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","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"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned upstream servers (MCP-2769). It is OFF by\ndefault and deliberately kept out of the AllowedSystemVars default list:\nproxy URLs frequently carry credentials (http://user:pass@proxy), so\nforwarding them to every stdio upstream is a credential-leak risk. When\nenabled, values are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"},"telemetry.FeedbackContext":{"properties":{"arch":{"type":"string"},"connected_server_count":{"type":"integer"},"edition":{"type":"string"},"os":{"type":"string"},"routing_mode":{"type":"string"},"server_count":{"type":"integer"},"version":{"type":"string"}},"type":"object"},"telemetry.FeedbackRequest":{"properties":{"category":{"description":"bug, feature, other","type":"string"},"context":{"$ref":"#/components/schemas/telemetry.FeedbackContext"},"email":{"type":"string"},"message":{"type":"string"}},"type":"object"},"telemetry.FeedbackResponse":{"properties":{"error":{"type":"string"},"issue_url":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"description":"API key authentication via query parameter. Use ?apikey=your-key","in":"query","name":"apikey","type":"apiKey"}}}, "info": {"contact":{"name":"MCPProxy Support","url":"https://github.com/smart-mcp-proxy/mcpproxy-go"},"description":"{{escape .Description}}","license":{"name":"MIT","url":"https://opensource.org/licenses/MIT"},"title":"{{.Title}}","version":"{{.Version}}"}, "externalDocs": {"description":"","url":""}, "paths": {"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP 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/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get usage statistics aggregate","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/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, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional parameters (server_name)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or entry not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disconnect MCPProxy from a client","tags":["connect"]},"get":{"description":"Resolves one client's status by reading its config file on demand.\nThis is the only Connect endpoint that opens a client config file, so\non macOS it is the sole place an App-Data privacy prompt may legitimately\nappear (scoped to this user action). Resolves access_state to\naccessible|absent|denied|malformed and populates remediation when denied.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get a single client's connection status (on-demand)","tags":["connect"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/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"]},"post":{"description":"Adds a generic modelcontextprotocol/registry v0.1 https endpoint as a custom registry (MCP-866). The source is always tagged custom/unverified, so every server discovered through it lands quarantined and can never skip quarantine.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddRegistrySourceRequest"}}},"description":"Registry source (https url + optional protocol/id/name)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source added"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin | duplicate_registry"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a user-supplied registry source","tags":["registries"]}},"/api/v1/registries/{id}":{"delete":{"description":"Removes a custom/unverified registry previously added via add-source (MCP-1057). Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source removed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove a user-added custom registry source","tags":["registries"]},"put":{"description":"Updates a custom registry previously added via add-source (MCP-1072): name, url, servers-url. Empty fields are left unchanged. Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found; a non-https url yields invalid_registry_url. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.EditRegistrySourceRequest"}}},"description":"Fields to update (name/url/servers_url; empty = unchanged)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required | invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Edit a user-added custom registry source","tags":["registries"]}},"/api/v1/registries/{id}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"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/block":{"post":{"description":"Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The \"blocked\" field counts tools actually blocked.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Block result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Block (approve+disable) tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/disable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/enable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/unquarantine":{"post":{"description":"Remove a specific upstream MCP server from quarantine to allow tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server unquarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Unquarantine a server","tags":["servers"]}},"/api/v1/sessions":{"get":{"description":"Retrieves paginated list of active and recent MCP client sessions. Each session represents a connection from an MCP client to MCPProxy, tracking initialization time, tool calls, and connection status.","parameters":[{"description":"Maximum number of sessions to return (1-100, default 10)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of sessions to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get sessions"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get active MCP sessions","tags":["sessions"]}},"/api/v1/sessions/{id}":{"get":{"description":"Retrieves detailed information about a specific MCP client session including initialization parameters, connection status, tool call count, and activity timestamps.","parameters":[{"description":"Session ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionDetailResponse"}}},"description":"Session details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get MCP session details by ID","tags":["sessions"]}},"/api/v1/stats/tokens":{"get":{"description":"Retrieve token savings statistics across all servers and sessions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Token statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get token savings statistics","tags":["stats"]}},"/api/v1/status":{"get":{"description":"Get comprehensive server status including running state, listen address, upstream statistics, and timestamp","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server status","tags":["status"]}},"/api/v1/telemetry/payload":{"get":{"description":"Render the exact JSON heartbeat payload that mcpproxy would next send to the telemetry endpoint, without making a network call. Counters in the payload reflect the current in-memory state. Spec 042.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Telemetry heartbeat payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}}, diff --git a/oas/swagger.yaml b/oas/swagger.yaml index fd73ba125..8ac493939 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -191,6 +191,19 @@ components: $ref: '#/components/schemas/config.TokenizerConfig' tool_discovery_interval: type: string + tool_output_max_bytes: + description: |- + ToolOutputMaxBytes caps the size of a single save_to_file write (Spec 076). + 0 means "use the built-in default" (50 MiB); negative values are a config error. + type: integer + tool_output_roots: + description: |- + ToolOutputRoots is the whitelist of absolute directory prefixes the + save_to_file parameter on call_tool_read/write/destructive is allowed + to write under (Spec 076). Empty (the default) disables the feature. + items: + type: string + type: array tool_response_limit: type: integer tool_response_session_risk_warning: diff --git a/specs/076-tool-output-save-to-file/spec.md b/specs/076-tool-output-save-to-file/spec.md new file mode 100644 index 000000000..912cad5c2 --- /dev/null +++ b/specs/076-tool-output-save-to-file/spec.md @@ -0,0 +1,291 @@ +# Feature Specification: Save Tool Output to File + +**Feature Branch**: `feat/tool-response-save-to-file` +**Created**: 2026-08-27 +**Status**: Draft +**Input**: Internal request — agents driving `call_tool_read` / `call_tool_write` / +`call_tool_destructive` against upstream MCP servers regularly receive +responses far larger than the configured `tool_response_limit`, and the +truncated/cached response is not always what the caller actually needs: +sometimes the full untruncated payload should be written straight to disk +for the calling process (or a human) to consume directly. + +## Background + +`tool_response_limit` already protects the agent's context window by +truncating and caching oversized tool responses (see +`docs/configuration.md`). That mechanism is the right default, but it +throws away the byte-for-byte original response — a caller who actually +wants the full payload (a large log dump, a full file listing, a big JSON +API response) has no way to get it without re-running the tool against a +raised limit, which reintroduces the very context-window problem the +limit exists to prevent. + +This feature adds an opt-in `save_to_file` parameter to the shared +`call_tool_*` handler. When present, mcpproxy writes the **full, +untruncated** upstream response to a file under an operator-configured +whitelist of directories and returns a small JSON envelope (path, byte +count, block counts) in place of the response body. The response-limit +truncator is bypassed entirely for a saved call — there is nothing left +to truncate. + +Because this hands an MCP client-supplied string almost directly to the +filesystem, the design treats path resolution as security-critical from +the outset: every target path is resolved through a directory whitelist +(`tool_output_roots`), and the write is confined through a single Go 1.25 +`os.Root` handle opened once, immediately after the whitelist match, and +used for every filesystem operation the write performs. This closes a +symlink or rename planted **inside** the root, or a replacement of the +root directory itself, in the gap between the check and the write — see +Security Considerations below for the precise boundary (what this does and +does not close). + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Save a large response to a whitelisted file (Priority: P1) + +An agent calls a tool through `call_tool_read` and expects a large +response (e.g. a full directory listing or log export). It sets +`save_to_file` to an absolute path under a directory the operator has +whitelisted in `tool_output_roots`. Instead of a truncated response, the +agent gets back a short envelope confirming the file was written, and can +then read the file directly (or hand the path to a human). + +**Why this priority**: This is the entire point of the feature — without +it, large-response workflows have no better option than raising +`tool_response_limit` globally, which defeats its purpose for every other +call. + +**Independent Test**: Configure `tool_output_roots` with a temp +directory, call a tool with `save_to_file` set to a path under it, and +confirm (a) the response is the JSON envelope, not the tool's own +content, and (b) the file on disk contains the full, untruncated +response. + +**Acceptance Scenarios**: + +1. **Given** `tool_output_roots` includes `/tmp/agent-out`, **When** a + call sets `save_to_file: "/tmp/agent-out/result.txt"`, **Then** the + file is created with the concatenated text content of the response and + the tool call returns a JSON envelope with the fields `saved_to`, + `bytes`, `sha256`, `format`, `content_blocks`, `non_text_blocks`, + `preview`, `truncated_preview` instead of the raw content. + `content_blocks` is the TOTAL content-block count (text and non-text + together), not a text-only count. +2. **Given** `save_format` is omitted, **When** `save_to_file` is set, + **Then** the default format is `"text"` (concatenated text blocks + only). +3. **Given** `save_format: "json"`, **When** `save_to_file` is set, + **Then** the file contains the full `json.Marshal` of the tool result, + including non-text content blocks. + +--- + +### User Story 2 - Reject paths outside the whitelist, including via symlink tricks (Priority: P1) + +An operator has whitelisted `/data/agent-out` but not the rest of the +filesystem. A malicious or buggy tool call attempts to write outside that +directory — directly (`../../etc/passwd`), or indirectly by racing a +symlink into place between mcpproxy's validation check and its actual +write. Both must be rejected; the symlink race in particular must be +closed structurally, not just checked-then-hoped. + +**Why this priority**: `save_to_file` is the first feature in this +codebase that lets an MCP client's string parameter reach the filesystem +almost directly. A path-escape or TOCTOU bug here is a full write-primitive +vulnerability, not a cosmetic bug. + +**Independent Test**: Attempt a save outside every configured root +(rejected), attempt a save through a directory symlink that is swapped +after the whitelist check but before the write completes (rejected — this +is the case `os.Root` structurally closes, see Security Considerations), +and attempt a save whose target path is itself the whitelisted root +(rejected as `ErrInvalidPath` — "target must be a file inside a root, not +the root itself") or the filesystem root `/` (rejected as `ErrOutsideRoots` +— `/` does not fall inside any configured root prefix; these two take +different code paths even though both are rejected). + +**Acceptance Scenarios**: + +1. **Given** `tool_output_roots: ["/data/agent-out"]`, **When** + `save_to_file` resolves outside that prefix (including via `..` + segments or an absolute path elsewhere), **Then** the call returns a + tool error (`ErrOutsideRoots`) and nothing is written. +2. **Given** an intermediate directory under a whitelisted root that is + swapped for a symlink pointing outside the root between `Resolve` and + the write, **When** the write executes, **Then** the write fails and + nothing is written outside the root — the already-open `os.Root` handle + opened by `Resolve` refuses to follow the symlink out of the root it + was opened against (see Security Considerations for the exact boundary, + including the case of the root's own path being swapped instead). +3. **Given** `save_to_file` is set to exactly a configured root directory + (no filename), **When** the call is made, **Then** it is rejected as + `ErrInvalidPath` rather than silently creating files inside the root + under an unintended name. +4. **Given** `tool_output_roots` contains `/` (the filesystem root), + **When** the configuration is validated, **Then** it is rejected at + load time with an explanatory error, since a root of `/` cannot ever + satisfy the whitelist's prefix-match and is therefore a silently + useless (not merely permissive) configuration value. + +--- + +### User Story 3 - Config changes take effect without a restart (Priority: P2) + +An operator changes `tool_output_roots` or `tool_output_max_bytes` via +the Web UI, the REST config endpoint, or by editing the config file with +hot reload enabled. The very next `save_to_file` call must honor the new +value — not the value that was live when the proxy process started. + +**Why this priority**: mcpproxy's config hot-reload already covers most +settings (including the closely related `tool_response_limit`); a setting +that silently requires a full process restart to take effect is a +foot-gun, especially for a security-relevant whitelist an operator might +urgently want to narrow. + +**Independent Test**: Start the proxy with one `tool_output_roots` value, +change it via hot reload, and confirm the very next `save_to_file` call +is validated against the new roots without restarting the process. + +**Acceptance Scenarios**: + +1. **Given** the proxy is running with `tool_output_roots: ["/a"]`, + **When** the config is hot-reloaded to `["/b"]`, **Then** a + `save_to_file` call targeting `/a/...` is rejected and one targeting + `/b/...` succeeds, without a restart. +2. **Given** `tool_output_max_bytes` is lowered via hot reload, **When** + the next call attempts to save a response larger than the new limit, + **Then** it is rejected against the new limit, not the one in effect + at process start. + +--- + +### User Story 4 - A failed save still produces an audit trail (Priority: P2) + +An agent attempts a `save_to_file` call that fails (path outside the +whitelist, file already exists without `save_overwrite`, response too +large). The operator needs this to show up in the tool-call history and +activity feed like any other failed call — not vanish silently, which +would make troubleshooting and security review impossible. + +**Why this priority**: A whitelist violation attempt is exactly the kind +of event an operator most wants visibility into; silently dropping it +from the audit trail defeats the purpose of having the whitelist be +observable at all. + +**Independent Test**: Trigger a save failure (e.g. `save_overwrite: +false` against an existing file) and confirm a tool-call record is +persisted with the error text, session stats are updated, and an activity +event is emitted with `status: "error"` — exactly as for any other failed +tool call. + +**Acceptance Scenarios**: + +1. **Given** a `save_to_file` call fails validation, **When** the handler + completes, **Then** `RecordToolCall`, `UpdateSessionStats`, and the + activity-completed event all still fire, with the error text captured + on the record. +2. **Given** a `save_to_file` call succeeds, **When** token metrics are + recorded, **Then** `OutputTokens` reflects the size of the short + envelope actually returned to the caller, not the size of the full + upstream body that was diverted to disk — otherwise the tool-call + history would over-report tokens the caller never actually received. + +### Edge Cases + +- **Deepest-existing-ancestor resolution for symlinked-but-not-fully-materialized paths**: a legitimate root or target can live under a path where only a prefix currently exists as a symlink (e.g. macOS's `/tmp` → `/private/tmp`, where `/tmp` exists but a deeper path segment doesn't yet). Resolution walks up to the deepest existing ancestor, resolves *that* through `EvalSymlinks`, and rejoins the remaining (not-yet-existing) suffix — a legitimate root under such a path must not be rejected merely because the full path doesn't exist yet. The root itself is then created (`MkdirAll`) before it is opened, so a configured root that doesn't exist yet at startup works end-to-end, not just at the resolution-check stage. +- **Zero text blocks with `save_format: "text"`**: if the upstream result has at least one content block but none of them is non-empty text (e.g. only image/audio blocks, or a lone empty-string text block), saving in text format must not silently write a 0-byte file and report success — this is treated as a tool error. A result with zero content blocks at all is different: it writes an empty file and succeeds, since a genuinely empty upstream response is a valid result rather than text dropped by the filter. +- **Legacy non-`*mcp.CallToolResult` result types**: a small number of code paths produce a result that is not the standard `*mcp.CallToolResult` type. `save_format: "json"` still saves a `json.Marshal` of whatever value it is; `save_format: "text"` (which has no text blocks to extract from an arbitrary type) is a tool error rather than a silent no-op. +- **File and directory permissions**: files written by `save_to_file` are created `0600` and any directories created along the way are `0700` — the feature must not leave saved tool output world- or group-readable by default. +- **Case-sensitive root matching**: whitelist prefix matching is case-sensitive (no `EqualFold`), consistent with POSIX filesystem semantics; this is called out explicitly in the docs so operators on case-insensitive filesystems (default macOS, Windows) understand two differently-cased configured roots are treated as distinct. +- **Daemon-mode file ownership**: when mcpproxy runs as a background daemon, `save_to_file` writes as whatever user/filesystem context the daemon process runs under, not the interactive CLI caller — operators must point `tool_output_roots` somewhere that user can write. +- **A directory component under a root is itself a symlink**: because the write is confined through a single `os.Root` handle rather than plain path-based filesystem calls, a symlink sitting where `save_to_file` needs to create or traverse a directory is not followed — the write fails with a plain filesystem error rather than silently landing wherever the symlink points. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST expose a `save_to_file` string parameter (an absolute filesystem path) on `call_tool_read`, `call_tool_write`, and `call_tool_destructive`, alongside `save_format` (`"text"` default or `"json"`) and `save_overwrite` (boolean, default `false`). +- **FR-002**: The system MUST expose a `tool_output_roots` configuration list (absolute directory prefixes) and a `tool_output_max_bytes` cap. `save_to_file` MUST be rejected as a no-op feature (or, if a path is supplied anyway, as a config/tool error) when `tool_output_roots` is empty. +- **FR-003**: Every `save_to_file` target path MUST resolve to a location inside one of the configured roots after resolving symlinks on existing ancestors. The write MUST be confined through a single Go 1.25 `os.Root` handle, opened once by `Resolve` immediately after the whitelist match, and used exclusively (never a path string again) for every filesystem operation the write performs — see Security Considerations for exactly what boundary this establishes. +- **FR-004**: A target path that resolves to a configured root itself (no filename) MUST be rejected as `ErrInvalidPath`, not silently accepted as a write inside the root. +- **FR-005**: A configured root equal to the filesystem root (`/`) MUST be rejected at config-validation time with a clear error, since it can never satisfy the whitelist's prefix-match semantics. +- **FR-006**: `save_format: "text"` MUST write the concatenation of the result's text content blocks (the same blocks the response-limit truncator would otherwise truncate), not a placeholder for non-text blocks; if at least one content block is present but none of them yields non-empty text, the call MUST fail as a tool error rather than writing an empty file and reporting success. A response with zero content blocks at all writes an empty file and succeeds — a genuinely empty upstream response is a valid result, not text dropped by the text-block filter. +- **FR-007**: `save_format: "json"` MUST write the full `json.Marshal` of the tool result, including non-text content. +- **FR-008**: `tool_output_roots` and `tool_output_max_bytes` MUST be read live at call time (mirroring the existing tokenizer-model-resolution pattern), so a hot-reloaded config change takes effect on the very next call without a process restart. +- **FR-009**: A `save_to_file` call — success or failure — MUST still produce a tool-call record, update session stats, and emit the standard tool-call-completed activity event; a failed save's error text MUST be captured on the record. +- **FR-010**: Token metrics for a saved call MUST be recounted against the actual envelope (or error text) returned to the caller, not the full upstream body diverted to disk; the metrics MUST record that the response was diverted (`SavedToFile`) separately from whether it was truncated (`WasTruncated`). +- **FR-011**: Files written by `save_to_file` MUST be created with `0600` permissions; any directories created along the way MUST be `0700`. +- **FR-012**: An existing file at the target path MUST cause the call to fail unless `save_overwrite: true` is set; the write MUST be atomic (write-then-rename) so a failed or interrupted write never leaves a partially-written file at the final path. +- **FR-013**: The CLI (`mcpproxy call`) MUST expose equivalent `--save-to-file` / `--save-format` / `--save-overwrite` flags mirroring the MCP tool parameters. + +### Key Entities + +- **`ToolOutputRoots`** (`[]string`, config): the whitelist of absolute directory prefixes `save_to_file` is allowed to write under. Empty disables the feature entirely. +- **`ToolOutputMaxBytes`** (`int64`, config): the cap on a single `save_to_file` write; `0` uses the built-in default, negative is a config error. +- **`Target`** (`internal/outputfile`): the result of resolving a candidate path against the whitelist. `Path` (the resolved absolute path) and `Root`/`Rel` (the matched root and the path relative to it) are retained for display and logging only; the actual write goes exclusively through `Handle`, an already-opened `*os.Root` — opened, and identity-checked against the directory it names, once by `Resolve` itself, immediately after the whitelist match succeeds. The caller owns the handle's lifecycle (`Target.Close()`) and must close it after the write, success or failure. +- **`TokenMetrics.SavedToFile`** (`bool`, storage): recorded alongside the pre-existing `WasTruncated` field, so a persisted tool-call record can distinguish "response was truncated in place" from "response was diverted to a file," independently. + +## Security Considerations + +- **What the confinement design closes**: `Resolve` matches the target + against the whitelist, creates the matched root if needed, opens it as a + single `*os.Root` handle, and identity-checks that handle against the + directory it names (`os.SameFile` on `handle.Stat(".")` vs a fresh + `os.Lstat` of the same path) before returning. `Write` uses only that + handle — never a path string — for every filesystem operation it + performs. This closes: (a) a symlink or rename planted **inside** the + root, at any path component the write needs to create or traverse, + between the resolve step and the write; (b) a replacement of the root + directory itself, or its own path, **after** the handle is open — once + open, a later swap of the root's path cannot move the underlying file + descriptor, so the write keeps operating on the original directory + `Resolve` validated, wherever it now lives. +- **What it does not close**: replacing an **ancestor** of a configured + root in the microseconds between `Resolve` resolving that ancestor's + symlinks and opening the root is not a boundary this design defends — + ancestors of a configured root are admin-controlled, and a same-user + process able to win that specific race window could already write + anywhere the mcpproxy process itself can write. Earlier drafts of this + feature described the confinement in unconditional terms ("cannot walk + the write outside the whitelist," "structurally close the gap, no matter + what happens"); this section is the precise replacement for those claims. +- **Saved content is not validated or spotlighted**: the redaction stage + (`applyOutputSanitisation`) still runs before a response is saved, so a + saved file never contains a secret redaction would otherwise have + stripped. Output-schema validation (Spec 056) and response spotlighting + (Spec 054) do not run on the save path at all — a saved file holds the + full, un-spotlighted upstream text as redaction left it. An agent that + reads a saved file back must treat its contents as untrusted data, the + same as it would any other unvalidated tool output; strict-mode + output-schema blocking does not apply to a saved response. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A `save_to_file` call against a whitelisted root writes a file containing the full, byte-for-byte (for `json` format) or fully-concatenated-untruncated (for `text` format) response, regardless of how far the response exceeds `tool_response_limit`. +- **SC-002**: Every attempted path-shape escape (relative traversal, absolute path outside all roots, target equal to a root or to `/`) and every intermediate-component symlink swap is rejected 100% of the time, with zero filesystem writes outside the whitelist in any test scenario. The root-swap case (the configured root directory itself renamed aside and a symlink planted at its path, after `Resolve` has already opened its `os.Root` handle) is different: the write is confined to the directory `Resolve` validated (which may since have been moved), never redirected to an attacker-chosen directory — see Security Considerations (b). +- **SC-003**: A `tool_output_roots` or `tool_output_max_bytes` change via hot reload is honored by the very next `save_to_file` call, with no process restart. +- **SC-004**: A failed `save_to_file` call always leaves exactly the same audit-trail shape (tool-call record, session stats, activity event) as any other failed tool call — never a silent no-record failure. +- **SC-005**: An existing configuration that never sets `tool_output_roots` behaves identically to before this feature existed (the parameter is accepted but the call fails cleanly with a clear "not configured" error rather than attempting a write). + +## Assumptions + +- Go 1.25's `os.Root` API is available in the pinned toolchain and provides the intended confinement semantics (verified against the actual `go.mod` toolchain version used to build this feature). +- Operators are expected to whitelist directories they control and trust for agent-written output; `save_to_file` is not a general-purpose sandboxed filesystem — it is a whitelist, not a jail against a root-equivalent adversary. +- Only the secret-redaction pipeline stage (`applyOutputSanitisation`) runs on a response before `save_to_file` diverts it — the response-limit truncator, output-schema validation (Spec 056), and response spotlighting (Spec 054) never run on the save path at all (see Security Considerations for the implications of this). +- The response-limit truncator specifically is bypassed entirely for a saved call: there is no truncated text to fall back to, since the point of `save_to_file` is to deliver the full untruncated body to disk instead. + +## Out of Scope + +- Extending the activity-event schema with dedicated `saved_to` / `saved_bytes` fields (rather than relying on the existing free-form response/status fields); may follow as a separate change. +- A general-purpose sandboxed filesystem, or defending `tool_output_roots` against a root-equivalent (same-user) local adversary — see Security Considerations for the precise threat model this feature does and does not cover. +- A dedicated array-of-strings control for `tool_output_roots` in the Settings UI; it is configured in the JSON config file only for now (the UI's existing free-text controls would silently corrupt a `[]string` value on save). +- Case-insensitive root matching (`EqualFold`); root matching stays case-sensitive, independent of the underlying filesystem's own case sensitivity. +- Cleaning up intermediate directories a `save_to_file` write created if a later step in that same write fails; a half-created, empty directory chain left behind is an accepted residual. + +## Commit Message Conventions *(mandatory)* + +- Conventional-commit style, e.g. `feat(server): save tool output to file`. +- Do **not** add AI co-authorship trailers (`AGENTS.md`: "avoid AI co-author tags").