diff --git a/CLAUDE.md b/CLAUDE.md index 3876b9c92..d4dc9785b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,6 +158,6 @@ tail -f ~/Library/Logs/mcpproxy/main.log # main log (macOS; Linux: ~/.mcpproxy/ - **Windows installer**: [docs/github-actions-windows-wix-research.md](docs/github-actions-windows-wix-research.md). **Prerelease** (`next` branch + `v*-rc.*` tags, opt-in, off stable channels): [docs/prerelease-builds.md](docs/prerelease-builds.md). ## Recent Changes +- 097-stored-scripts: Added Go 1.25 (os.Root/Root.ReadFile available — R1) + stdlib only (os.Root). **No new dependencies.** - 096-batched-call-tools: Added Go 1.24 module toolchain (repo builds with local Go 1.25) + existing only — goja (sandbox), mark3labs/mcp-go (tool surface), zap. **No new dependencies.** - 095-update-failure-ux: Added Swift 5.9 (tray, AppKit + Sparkle 2.9.3 vendored via SwiftPM) · Go 1.24 module toolchain (repo builds with local Go 1.25) + existing only — Sparkle 2.9.3 (`SPUUpdater`, `SPUStandardUserDriver`), chi (httpapi), bbolt (diagnostics counters), swaggo/swag v2 (contract regen). **No new dependencies.** -- 094-filter-diagnostics: Added Go 1.24 (module toolchain; repo builds with local Go 1.25) + existing only — `mark3labs/mcp-go` (tool registration), stdlib `encoding/json`. No new dependencies. diff --git a/ROADMAP.md b/ROADMAP.md index e8920e1a0..5c32426f6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -798,3 +798,4 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [094-filter-diagnostics](./specs/094-filter-diagnostics/) | `shipped` | 14/14 (100%) | | [095-update-failure-ux](./specs/095-update-failure-ux/) | `shipped` | 28/28 (100%) | | [096-batched-call-tools](./specs/096-batched-call-tools/) | `in-flight` | 15/16 (94%) | +| [097-stored-scripts](./specs/097-stored-scripts/) | `in-flight` | 13/14 (93%) | diff --git a/cmd/mcpproxy/code_cmd.go b/cmd/mcpproxy/code_cmd.go index 3b59b780f..c81bce3bb 100644 --- a/cmd/mcpproxy/code_cmd.go +++ b/cmd/mcpproxy/code_cmd.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "strings" @@ -12,6 +13,7 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/cache" "github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/codescripts" "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" "github.com/smart-mcp-proxy/mcpproxy-go/internal/index" "github.com/smart-mcp-proxy/mcpproxy-go/internal/secret" @@ -40,6 +42,13 @@ var ( Use --language typescript to write TypeScript code with type annotations, interfaces, enums, and generics. Types are automatically stripped before execution. +Use --script to run a script stored server-side in the scripts/ directory +next to the configuration file, instead of sending source with --code/--file. +Exactly one of --code, --file or --script may be given. The name is a bare +identifier, never a path, and its extension decides the language, so --language +is only needed for inline code. List what is stored with 'mcpproxy code scripts +list'. + The code has access to: - input: Global variable containing the input data (from --input or --input-file) - call_tool(serverName, toolName, args): Function to invoke upstream MCP tools @@ -56,6 +65,32 @@ Exit codes: RunE: runCodeExec, } + codeScriptsCmd = &cobra.Command{ + Use: "scripts", + Short: "Inspect the stored scripts available to code execution", + Long: `Stored scripts are ` + "`.js`" + ` / ` + "`.ts`" + ` files in the scripts/ directory +next to mcpproxy's configuration file. Run one with: + + mcpproxy code exec --script + +Scripts are authored in the filesystem — there is no command that writes them.`, + } + + codeScriptsListCmd = &cobra.Command{ + Use: "list", + Short: "List stored scripts", + Long: `List the stored scripts the code_execution tool can run. + +When a daemon is running the listing comes from the daemon, so it always +describes the process that actually resolves scripts; otherwise the local +scripts directory is read directly. + +Each entry carries a status: 'ok' (invocable), 'ambiguous' (both a .js and a .ts +file share the name — remove one) or 'invalid' with the reason it cannot run.`, + Args: cobra.NoArgs, + RunE: runCodeScriptsList, + } + // Command flags for code exec codeSource string codeFile string @@ -67,6 +102,12 @@ Exit codes: codeLogLevel string codeConfigPath string codeLanguage string + codeScriptName string + + // codeLanguageExplicit records whether --language was actually set by the + // user. The flag has a default ("javascript"), and a default is not a + // choice: forwarded as one it would contradict every stored .ts script. + codeLanguageExplicit bool ) // GetCodeCommand returns the code command for adding to the root command @@ -78,9 +119,15 @@ func init() { // Add exec subcommand to code command codeCmd.AddCommand(codeExecCmd) + // Stored-script discovery (Spec 097). Read-only: scripts are authored in + // the filesystem, never through the CLI. + codeScriptsCmd.AddCommand(codeScriptsListCmd) + codeCmd.AddCommand(codeScriptsCmd) + // Define flags for code exec command codeExecCmd.Flags().StringVar(&codeSource, "code", "", "JavaScript code to execute (required if --file is not provided)") codeExecCmd.Flags().StringVar(&codeFile, "file", "", "Path to JavaScript file to execute (required if --code is not provided)") + codeExecCmd.Flags().StringVar(&codeScriptName, "script", "", "Name of a stored script in the scripts/ directory next to the config file (mutually exclusive with --code/--file)") codeExecCmd.Flags().StringVar(&codeInput, "input", "{}", "Input data as JSON string (default: {})") codeExecCmd.Flags().StringVar(&codeInputFile, "input-file", "", "Path to JSON file containing input data") codeExecCmd.Flags().IntVar(&codeTimeout, "timeout", 120000, "Execution timeout in milliseconds (1-600000)") @@ -90,6 +137,10 @@ func init() { codeExecCmd.Flags().StringVarP(&codeConfigPath, "config", "c", "", "Path to MCP configuration file (default: ~/.mcpproxy/mcp_config.json)") codeExecCmd.Flags().StringVar(&codeLanguage, "language", "javascript", "Source code language: javascript, typescript") + // The scripts commands resolve the same config FILE as exec, so they take + // the same --config override. + codeScriptsListCmd.Flags().StringVarP(&codeConfigPath, "config", "c", "", "Path to MCP configuration file (default: ~/.mcpproxy/mcp_config.json)") + // Add examples codeExecCmd.Example = ` # Execute inline code with input mcpproxy code exec --code="({ result: input.value * 2 })" --input='{"value": 21}' @@ -103,6 +154,12 @@ func init() { # Execute TypeScript from file mcpproxy code exec --language typescript --file=script.ts --input-file=params.json + # Execute a stored script by name (scripts/ next to the config file) + mcpproxy code exec --script=daily-report --input='{"repo":"smart-mcp-proxy/mcpproxy-go"}' + + # See which stored scripts exist + mcpproxy code scripts list + # Call upstream tools mcpproxy code exec --code="call_tool('github', 'get_user', {username: input.user})" --input='{"user":"octocat"}' @@ -116,17 +173,17 @@ func init() { mcpproxy code exec --code="..." --log-level=trace` } -func runCodeExec(_ *cobra.Command, _ []string) error { +func runCodeExec(cmd *cobra.Command, _ []string) error { // Validate arguments - if codeSource == "" && codeFile == "" { - fmt.Fprintf(os.Stderr, "Error: either --code or --file must be provided\n") - return exitError(2) - } - if codeSource != "" && codeFile != "" { - fmt.Fprintf(os.Stderr, "Error: --code and --file are mutually exclusive\n") + if err := validateCodeSourceFlags(codeSource, codeFile, codeScriptName); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) return exitError(2) } + // Record whether --language is the user's choice or just its default, + // before either execution mode reads it. + setCodeLanguageExplicit(cmd) + // Load code and input code, inputData, err := loadCodeAndInput() if err != nil { @@ -218,7 +275,7 @@ func runCodeExecClientMode(client *cliclient.Client, code string, input map[stri codeTimeout, codeMaxToolCalls, codeAllowedSrvs, - cliclient.CodeExecOptions{Language: codeLanguage}, + cliclient.CodeExecOptions{Language: codeExecLanguageArg(), Script: codeScriptName}, ) if err != nil { if errors.Is(err, context.DeadlineExceeded) { @@ -278,6 +335,15 @@ func runCodeExecStandalone(globalConfig *config.Config, code string, input map[s // Create truncator truncator := truncate.NewTruncator(globalConfig.ToolResponseLimit) + // Spec 097: the in-process server needs the SAME config-file authority the + // daemon has, or a stored script resolves differently depending on whether + // a daemon happened to be running. A failure here is not fatal: only stored + // scripts depend on it, and they report their own error. + configFilePath, cfgPathErr := codeConfigFilePath() + if cfgPathErr != nil { + logger.Warn("could not resolve the config file path for stored scripts", zap.Error(cfgPathErr)) + } + // Create MCP proxy server mcpProxy := server.NewMCPProxyServer( storageManager, @@ -290,27 +356,12 @@ func runCodeExecStandalone(globalConfig *config.Config, code string, input map[s false, globalConfig, nil, // standalone one-shot: no runtime-owned signature cache + server.WithConfigFilePath(configFilePath), ) defer mcpProxy.Close() - // Build arguments for code_execution tool - args := map[string]interface{}{ - "code": code, - "input": input, - "options": map[string]interface{}{ - "timeout_ms": codeTimeout, - "max_tool_calls": codeMaxToolCalls, - "allowed_servers": codeAllowedSrvs, - }, - } - - // Pass language if not the default - if codeLanguage != "" && codeLanguage != "javascript" { - args["language"] = codeLanguage - } - // Call the code_execution tool - result, err := mcpProxy.CallBuiltInTool(ctx, "code_execution", args) + result, err := mcpProxy.CallBuiltInTool(ctx, "code_execution", codeExecToolArgs(code, codeScriptName, input)) if err != nil { fmt.Fprintf(os.Stderr, "Error calling code_execution tool: %v\n", err) return exitError(1) @@ -320,8 +371,175 @@ func runCodeExecStandalone(globalConfig *config.Config, code string, input map[s return outputResultFromMCP(result) } +// codeScriptsListTimeout bounds the daemon listing request: it is a directory +// read on the far side, so it either answers immediately or something is wrong. +const codeScriptsListTimeout = 10 * time.Second + +// codeScriptsPayload is the machine-readable shape of `code scripts list`, +// mirroring GET /api/v1/code/scripts so both surfaces read the same. +type codeScriptsPayload struct { + Dir string `json:"dir"` + Scripts []codescripts.Entry `json:"scripts"` +} + +// runCodeScriptsList lists the stored scripts available to code execution. +// A running daemon answers for itself — it is the process that resolves scripts +// at execution time, so its view is the authoritative one; only without a +// daemon does the CLI read the scripts directory itself. +func runCodeScriptsList(_ *cobra.Command, _ []string) error { + // A failed config load does not rule the daemon out: socket detection needs + // no config at all, so a daemon started with --config elsewhere is still + // reachable and still the authority. cfg is nil on error, which + // newDaemonClient already tolerates. + cfg, cfgErr := loadCodeConfig() + if client, ok := newDaemonClient(cfg, nil); ok { + ctx, cancel := context.WithTimeout(context.Background(), codeScriptsListTimeout) + defer cancel() + + dir, entries, err := client.GetCodeScripts(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "Error listing stored scripts from the daemon: %s\n", formatErrorWithRequestID(err)) + return exitError(1) + } + return outputCodeScripts(dir, entries) + } + + // Falling back silently was the trap: with no config loaded this reads the + // DEFAULT scripts directory and reports it as the answer, so a daemon + // serving a different directory is contradicted by a listing that looks + // authoritative. The local answer is still the best available, but the + // reader has to know that is what it is. + if cfgErr != nil { + fmt.Fprintf(os.Stderr, "Warning: could not load configuration (%v); listing the local scripts directory only\n", cfgErr) + } + + dir, entries, err := localCodeScripts() + if err != nil { + fmt.Fprintf(os.Stderr, "Error listing stored scripts: %v\n", err) + return exitError(1) + } + return outputCodeScripts(dir, entries) +} + +// localCodeScripts lists the scripts directory belonging to the config FILE the +// code command works against — the same authority the in-process handler uses, +// so a daemonless listing cannot disagree with a daemonless execution. +func localCodeScripts() (string, []codescripts.Entry, error) { + configFilePath, err := codeConfigFilePath() + if err != nil { + return "", nil, err + } + dir := codescripts.DirFor(configFilePath) + entries, err := codescripts.List(dir) + if err != nil { + return dir, nil, err + } + return dir, entries, nil +} + +func outputCodeScripts(dir string, entries []codescripts.Entry) error { + if format := ResolveOutputFormat(); format == "json" || format == "yaml" { + formatter, err := GetOutputFormatter() + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating output formatter: %v\n", err) + return exitError(1) + } + out, err := formatter.Format(codeScriptsPayload{Dir: dir, Scripts: entries}) + if err != nil { + fmt.Fprintf(os.Stderr, "Error formatting stored scripts: %v\n", err) + return exitError(1) + } + fmt.Println(out) + return nil + } + + renderCodeScripts(os.Stdout, dir, entries) + return nil +} + +// renderCodeScripts writes the human-readable listing. It always names the +// directory it read: "no scripts" and "scripts, but not where you think" look +// identical otherwise. +func renderCodeScripts(w io.Writer, dir string, entries []codescripts.Entry) { + if len(entries) == 0 { + fmt.Fprintf(w, "No stored scripts in %s\n", dir) + fmt.Fprintf(w, "Create .js or .ts there, then run: mcpproxy code exec --script \n") + return + } + + fmt.Fprintf(w, "Stored scripts in %s (%d):\n", dir, len(entries)) + for _, entry := range entries { + status := string(entry.Status) + if entry.Reason != "" { + status += " (" + entry.Reason + ")" + } + fmt.Fprintf(w, " %-32s %-20s %s\n", entry.Name, status, strings.Join(entry.Paths, ", ")) + } + fmt.Fprintf(w, "\nRun one with: mcpproxy code exec --script \n") +} + // Helper functions +// validateCodeSourceFlags enforces the exactly-one-source rule across the three +// ways to name what runs: inline --code, a local --file, or a server-side +// stored script by --script (Spec 097). +func validateCodeSourceFlags(code, file, script string) error { + named := 0 + for _, v := range []string{code, file, script} { + if v != "" { + named++ + } + } + switch { + case named == 0: + return fmt.Errorf("one of --code, --file or --script must be provided") + case named > 1: + return fmt.Errorf("--code, --file and --script are mutually exclusive") + } + return nil +} + +// setCodeLanguageExplicit records whether --language carries the user's own +// choice or merely its default value. +func setCodeLanguageExplicit(cmd *cobra.Command) { + codeLanguageExplicit = cmd != nil && cmd.Flags().Changed("language") +} + +// codeExecLanguageArg returns the language to send with the request: the flag's +// value only when the user actually set it. A stored script derives its +// language from the file extension and rejects a contradicting explicit one, so +// forwarding the flag's "javascript" default would break every .ts script. +func codeExecLanguageArg() string { + if !codeLanguageExplicit { + return "" + } + return codeLanguage +} + +// codeExecToolArgs builds the code_execution arguments for standalone +// (in-process) execution. A stored script contributes its NAME, never content +// the CLI resolved itself: the handler is the only execution-time resolver, so +// the same name means the same thing whether or not a daemon is running. +func codeExecToolArgs(code, script string, input map[string]interface{}) map[string]interface{} { + args := map[string]interface{}{ + "input": input, + "options": map[string]interface{}{ + "timeout_ms": codeTimeout, + "max_tool_calls": codeMaxToolCalls, + "allowed_servers": codeAllowedSrvs, + }, + } + if script != "" { + args["script"] = script + } else { + args["code"] = code + } + if language := codeExecLanguageArg(); language != "" { + args["language"] = language + } + return args +} + func loadCodeAndInput() (string, map[string]interface{}, error) { var code string if codeFile != "" { @@ -387,6 +605,21 @@ func outputResult(result *cliclient.CodeExecResult) error { } func outputResultFromMCP(result *mcp.CallToolResult) error { + // A tool ERROR is plain text, not the execution envelope — and for a stored + // script that text is the recovery path: naming one that does not exist + // answers with the available names (FR-004). Parsing it as JSON and giving + // up ("unexpected result format") threw that away. + if result.IsError { + for _, content := range result.Content { + if textContent, ok := mcp.AsTextContent(content); ok { + fmt.Fprintf(os.Stderr, "Error: %s\n", textContent.Text) + return exitError(1) + } + } + fmt.Fprintf(os.Stderr, "Error: code execution failed\n") + return exitError(1) + } + // Existing logic to parse MCP result for _, content := range result.Content { if textContent, ok := mcp.AsTextContent(content); ok { @@ -411,19 +644,27 @@ func outputResultFromMCP(result *mcp.CallToolResult) error { return exitError(1) } +// codeConfigFilePath resolves the config FILE the code command works against: +// --config when given, else the documented default. It is deliberately NOT +// derived from --data-dir — that flag overrides the data directory AFTER the +// config file has been chosen, so deriving from it would disagree with the +// file actually loaded (and with the daemon) about where stored scripts live. +func codeConfigFilePath() (string, error) { + if codeConfigPath != "" { + return codeConfigPath, nil + } + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + return filepath.Join(homeDir, ".mcpproxy", "mcp_config.json"), nil +} + // loadCodeConfig loads the MCP configuration file for code command func loadCodeConfig() (*config.Config, error) { - var configFilePath string - - if codeConfigPath != "" { - configFilePath = codeConfigPath - } else { - // Use default path - homeDir, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("failed to get user home directory: %w", err) - } - configFilePath = filepath.Join(homeDir, ".mcpproxy", "mcp_config.json") + configFilePath, err := codeConfigFilePath() + if err != nil { + return nil, err } // Check if config file exists diff --git a/cmd/mcpproxy/code_config_path_test.go b/cmd/mcpproxy/code_config_path_test.go new file mode 100644 index 000000000..177bef2a0 --- /dev/null +++ b/cmd/mcpproxy/code_config_path_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestCodeConfigFilePath pins the CLI half of the Spec 097 config-path +// authority: the in-process server is handed the ACTIVE config FILE path, and +// that path comes from --config or the documented default — never from +// --data-dir, which the loader may override afterwards (research R2 trap 2). +func TestCodeConfigFilePath(t *testing.T) { + previousConfig, previousDataDir := codeConfigPath, dataDir + t.Cleanup(func() { codeConfigPath, dataDir = previousConfig, previousDataDir }) + + t.Run("defaults to the standard config file", func(t *testing.T) { + codeConfigPath, dataDir = "", "" + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("no home directory in this environment: %v", err) + } + + got, err := codeConfigFilePath() + if err != nil { + t.Fatalf("codeConfigFilePath() error: %v", err) + } + want := filepath.Join(home, ".mcpproxy", "mcp_config.json") + if got != want { + t.Fatalf("codeConfigFilePath() = %q, want %q", got, want) + } + }) + + t.Run("--config wins", func(t *testing.T) { + custom := filepath.Join(t.TempDir(), "custom", "mcp_config.json") + codeConfigPath, dataDir = custom, "" + + got, err := codeConfigFilePath() + if err != nil { + t.Fatalf("codeConfigFilePath() error: %v", err) + } + if got != custom { + t.Fatalf("codeConfigFilePath() = %q, want %q", got, custom) + } + }) + + t.Run("--data-dir does not move the config file", func(t *testing.T) { + custom := filepath.Join(t.TempDir(), "custom", "mcp_config.json") + codeConfigPath, dataDir = custom, t.TempDir() + + got, err := codeConfigFilePath() + if err != nil { + t.Fatalf("codeConfigFilePath() error: %v", err) + } + if got != custom { + t.Fatalf("codeConfigFilePath() = %q, want %q — --data-dir must not redirect script resolution", got, custom) + } + }) +} diff --git a/cmd/mcpproxy/code_script_cmd_test.go b/cmd/mcpproxy/code_script_cmd_test.go new file mode 100644 index 000000000..7ae766cc2 --- /dev/null +++ b/cmd/mcpproxy/code_script_cmd_test.go @@ -0,0 +1,691 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/codescripts" + + "go.uber.org/zap" +) + +// TestValidateCodeSourceFlags (Spec 097, T006) pins the three-way exclusion: +// exactly one of --code, --file or --script names the source to run. +func TestValidateCodeSourceFlags(t *testing.T) { + tests := []struct { + name string + code string + file string + script string + wantErr string + }{ + {name: "code alone", code: "({})"}, + {name: "file alone", file: "script.js"}, + {name: "script alone", script: "daily-report"}, + { + name: "nothing at all", + wantErr: "one of --code, --file or --script", + }, + { + name: "code and file", + code: "({})", + file: "script.js", + wantErr: "mutually exclusive", + }, + { + name: "script and code", + code: "({})", + script: "daily-report", + wantErr: "mutually exclusive", + }, + { + name: "script and file", + file: "script.js", + script: "daily-report", + wantErr: "mutually exclusive", + }, + { + name: "all three", + code: "({})", + file: "script.js", + script: "daily-report", + wantErr: "mutually exclusive", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := validateCodeSourceFlags(tc.code, tc.file, tc.script) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("validateCodeSourceFlags(%q, %q, %q) = %v, want nil", tc.code, tc.file, tc.script, err) + } + return + } + if err == nil { + t.Fatalf("validateCodeSourceFlags(%q, %q, %q) = nil, want an error mentioning %q", tc.code, tc.file, tc.script, tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error %q does not mention %q", err, tc.wantErr) + } + }) + } +} + +// TestCodeExecLanguageArg pins that --language reaches the daemon ONLY when the +// user actually set it. The flag's own default ("javascript") must never travel +// as an explicit choice: against a stored .ts script it would fake a +// contradiction and turn every TypeScript script into an error. +func TestCodeExecLanguageArg(t *testing.T) { + previousValue, previousExplicit := codeLanguage, codeLanguageExplicit + t.Cleanup(func() { codeLanguage, codeLanguageExplicit = previousValue, previousExplicit }) + + codeLanguage, codeLanguageExplicit = "javascript", false + if got := codeExecLanguageArg(); got != "" { + t.Fatalf("codeExecLanguageArg() = %q with the flag untouched, want empty", got) + } + + codeLanguage, codeLanguageExplicit = "typescript", true + if got := codeExecLanguageArg(); got != "typescript" { + t.Fatalf("codeExecLanguageArg() = %q, want %q", got, "typescript") + } + + // An explicitly requested "javascript" is a real choice and must travel, so + // a .ts stored script reports the contradiction instead of hiding it. + codeLanguage, codeLanguageExplicit = "javascript", true + if got := codeExecLanguageArg(); got != "javascript" { + t.Fatalf("codeExecLanguageArg() = %q for an explicit --language javascript, want it forwarded", got) + } +} + +// TestSetCodeLanguageExplicit pins the wiring between the cobra flag and the +// value codeExecLanguageArg reports. +func TestSetCodeLanguageExplicit(t *testing.T) { + previous := codeLanguageExplicit + flag := codeExecCmd.Flags().Lookup("language") + if flag == nil { + t.Fatal("code exec has no --language flag") + } + previousChanged, previousValue := flag.Changed, codeLanguage + t.Cleanup(func() { + codeLanguageExplicit = previous + flag.Changed = previousChanged + codeLanguage = previousValue + _ = codeExecCmd.Flags().Set("language", previousValue) + flag.Changed = previousChanged + }) + + flag.Changed = false + setCodeLanguageExplicit(codeExecCmd) + if codeLanguageExplicit { + t.Fatal("an untouched --language must not count as explicit") + } + + if err := codeExecCmd.Flags().Set("language", "typescript"); err != nil { + t.Fatalf("failed to set --language: %v", err) + } + setCodeLanguageExplicit(codeExecCmd) + if !codeLanguageExplicit { + t.Fatal("a --language the user set must count as explicit") + } +} + +// TestCodeExecToolArgs pins what standalone (in-process) mode hands the +// code_execution tool: for a stored script the NAME, never content the CLI +// resolved itself — the handler is the only execution-time resolver on every +// surface. +func TestCodeExecToolArgs(t *testing.T) { + previousValue, previousExplicit := codeLanguage, codeLanguageExplicit + t.Cleanup(func() { codeLanguage, codeLanguageExplicit = previousValue, previousExplicit }) + codeLanguage, codeLanguageExplicit = "javascript", false + + input := map[string]interface{}{"value": 21} + + t.Run("stored script sends the name only", func(t *testing.T) { + args := codeExecToolArgs("", "daily-report", input) + if args["script"] != "daily-report" { + t.Fatalf("args[script] = %v, want %q", args["script"], "daily-report") + } + if _, present := args["code"]; present { + t.Fatalf("a stored-script invocation must send no code: %v", args) + } + if _, present := args["language"]; present { + t.Fatalf("an untouched --language must not be sent: %v", args) + } + }) + + t.Run("inline code sends the source", func(t *testing.T) { + args := codeExecToolArgs("({result: 1})", "", input) + if args["code"] != "({result: 1})" { + t.Fatalf("args[code] = %v", args["code"]) + } + if _, present := args["script"]; present { + t.Fatalf("an inline invocation must send no script name: %v", args) + } + }) + + t.Run("an explicit language travels", func(t *testing.T) { + codeLanguage, codeLanguageExplicit = "typescript", true + args := codeExecToolArgs("const x: number = 1", "", input) + if args["language"] != "typescript" { + t.Fatalf("args[language] = %v, want typescript", args["language"]) + } + }) +} + +const ( + codeScriptDaemonChildEnv = "MCPPROXY_TEST_CODE_SCRIPT_CHILD" + codeScriptFallbackChildEnv = "MCPPROXY_TEST_CODE_SCRIPT_FALLBACK_CHILD" +) + +// TestRunCodeExecClientMode_SendsScriptName (T006) drives the real daemon-mode +// path with --script and asserts on the request the daemon actually receives: +// the script NAME and no source. The command path calls os.Exit on failure, so +// it runs in a child process. +func TestRunCodeExecClientMode_SendsScriptName(t *testing.T) { + if os.Getenv(codeScriptDaemonChildEnv) == "1" { + codeTimeout = 60000 + codeMaxToolCalls = 0 + codeAllowedSrvs = nil + codeLanguage = "javascript" + codeLanguageExplicit = false + codeScriptName = "daily-report" + useMissingCodeConfig(t) + + client := cliclient.NewClientWithAPIKey(os.Getenv(codeScriptDaemonChildEnv+"_ENDPOINT"), "", nil) + if err := runCodeExecClientMode(client, "", map[string]interface{}{"value": 1}, zap.NewNop()); err != nil { + t.Fatalf("runCodeExecClientMode returned error: %v", err) + } + return + } + + type capture struct { + body map[string]interface{} + } + got := &capture{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/status": + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": true}) + case "/api/v1/code/exec": + _ = json.NewDecoder(r.Body).Decode(&got.body) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "result": map[string]interface{}{"value": 1}}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + cmd := exec.Command(os.Args[0], "-test.run=^TestRunCodeExecClientMode_SendsScriptName$", "-test.timeout=60s") + cmd.Env = append(os.Environ(), + codeScriptDaemonChildEnv+"=1", + codeScriptDaemonChildEnv+"_ENDPOINT="+srv.URL, + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("daemon-mode stored-script exec failed (%v)\nchild output:\n%s", err, out) + } + if !strings.Contains(string(out), "Using daemon mode") { + t.Fatalf("child never took the daemon path\nchild output:\n%s", out) + } + if got.body == nil { + t.Fatalf("the daemon never received a code execution request\nchild output:\n%s", out) + } + if got.body["script"] != "daily-report" { + t.Fatalf("request body script = %v, want %q (body: %v)", got.body["script"], "daily-report", got.body) + } + if code, present := got.body["code"]; present && code != "" { + t.Fatalf("daemon mode must send the script name, not its content (code=%q)", code) + } + if _, present := got.body["language"]; present { + t.Fatalf("an untouched --language must not be sent: %v", got.body) + } +} + +// TestRunCodeExecClientMode_ScriptSurvivesPingFallback (T006) pins that a dead +// daemon does not change what --script means: the standalone fallback runs the +// SAME name through the in-process handler, whose authority is the shared +// config-path helper. +func TestRunCodeExecClientMode_ScriptSurvivesPingFallback(t *testing.T) { + if os.Getenv(codeScriptFallbackChildEnv) == "1" { + codeTimeout = 20000 + codeMaxToolCalls = 0 + codeAllowedSrvs = nil + codeLanguage = "javascript" + codeLanguageExplicit = false + codeScriptName = "fallback" + + dir := os.Getenv(codeScriptFallbackChildEnv + "_DIR") + codeConfigPath = filepath.Join(dir, "mcp_config.json") + t.Cleanup(func() { codeConfigPath = "" }) + + // Port 1 refuses connections immediately, so the ping fails fast. + client := cliclient.NewClientWithAPIKey("http://127.0.0.1:1", "", nil) + _ = runCodeExecClientMode(client, "", map[string]interface{}{"value": 20}, zap.NewNop()) + return + } + + dir := t.TempDir() + cfg := map[string]interface{}{ + "listen": "127.0.0.1:0", + "data_dir": dir, + "enable_code_execution": true, + "code_execution_pool_size": 1, + } + cfgBytes, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "mcp_config.json"), cfgBytes, 0o600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + scriptsDir := filepath.Join(dir, "scripts") + if err := os.MkdirAll(scriptsDir, 0o755); err != nil { + t.Fatalf("failed to create scripts dir: %v", err) + } + if err := os.WriteFile(filepath.Join(scriptsDir, "fallback.js"), []byte("({result: input.value + 1})"), 0o600); err != nil { + t.Fatalf("failed to write stored script: %v", err) + } + + cmd := exec.Command(os.Args[0], "-test.run=^TestRunCodeExecClientMode_ScriptSurvivesPingFallback$", "-test.timeout=120s") + cmd.Env = append(os.Environ(), + codeScriptFallbackChildEnv+"=1", + codeScriptFallbackChildEnv+"_DIR="+dir, + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("standalone fallback failed for a stored script (%v)\nchild output:\n%s", err, out) + } + if !strings.Contains(string(out), "standalone mode") { + t.Fatalf("child never fell back to standalone mode\nchild output:\n%s", out) + } + if !strings.Contains(string(out), `"result": 21`) { + t.Fatalf("the stored script did not run through the in-process handler\nchild output:\n%s", out) + } +} + +// TestLocalCodeScripts (T010) pins the daemonless listing: it reads the scripts +// directory implied by the config FILE the code command works against, and +// reports every entry with its status. +func TestLocalCodeScripts(t *testing.T) { + dir := t.TempDir() + previous := codeConfigPath + codeConfigPath = filepath.Join(dir, "mcp_config.json") + t.Cleanup(func() { codeConfigPath = previous }) + + scriptsDir := filepath.Join(dir, "scripts") + if err := os.MkdirAll(scriptsDir, 0o755); err != nil { + t.Fatalf("failed to create scripts dir: %v", err) + } + if err := os.WriteFile(filepath.Join(scriptsDir, "alpha.js"), []byte("({a: 1})"), 0o600); err != nil { + t.Fatalf("failed to write script: %v", err) + } + if err := os.WriteFile(filepath.Join(scriptsDir, "blank.ts"), nil, 0o600); err != nil { + t.Fatalf("failed to write script: %v", err) + } + + gotDir, entries, err := localCodeScripts() + if err != nil { + t.Fatalf("localCodeScripts returned error: %v", err) + } + if gotDir != scriptsDir { + t.Fatalf("dir = %q, want %q", gotDir, scriptsDir) + } + if len(entries) != 2 { + t.Fatalf("entries = %+v, want 2", entries) + } + if entries[0].Name != "alpha" || entries[0].Status != codescripts.StatusOK { + t.Fatalf("entries[0] = %+v", entries[0]) + } + if entries[1].Name != "blank" || entries[1].Status != codescripts.StatusInvalid || entries[1].Reason != codescripts.ReasonEmpty { + t.Fatalf("entries[1] = %+v", entries[1]) + } +} + +// TestRenderCodeScripts pins the human-readable listing: the directory that was +// read, every name, and the reason an unusable entry cannot be invoked. +func TestRenderCodeScripts(t *testing.T) { + var out strings.Builder + renderCodeScripts(&out, "/cfg/scripts", []codescripts.Entry{ + {Name: "alpha", Paths: []string{"/cfg/scripts/alpha.js"}, Status: codescripts.StatusOK}, + {Name: "blank", Paths: []string{"/cfg/scripts/blank.js"}, Status: codescripts.StatusInvalid, Reason: codescripts.ReasonEmpty}, + }) + text := out.String() + for _, want := range []string{"/cfg/scripts", "alpha", "blank", codescripts.ReasonEmpty} { + if !strings.Contains(text, want) { + t.Fatalf("listing does not mention %q:\n%s", want, text) + } + } + + var empty strings.Builder + renderCodeScripts(&empty, "/cfg/scripts", nil) + if !strings.Contains(empty.String(), "/cfg/scripts") { + t.Fatalf("an empty listing must still name the directory searched:\n%s", empty.String()) + } +} + +const ( + codeScriptsListChildEnv = "MCPPROXY_TEST_CODE_SCRIPTS_LIST_CHILD" + codeScriptsListJSONChildEnv = "MCPPROXY_TEST_CODE_SCRIPTS_LIST_JSON_CHILD" +) + +// TestRunCodeScriptsList_DaemonPath (T010) drives `code scripts list` against a +// running daemon: the daemon's answer is what the user sees, not a local +// re-listing that could disagree with the process that actually executes. +func TestRunCodeScriptsList_DaemonPath(t *testing.T) { + if os.Getenv(codeScriptsListChildEnv) == "1" { + dir := os.Getenv(codeScriptsListChildEnv + "_DIR") + codeConfigPath = filepath.Join(dir, "mcp_config.json") + t.Cleanup(func() { codeConfigPath = "" }) + if err := runCodeScriptsList(codeScriptsListCmd, nil); err != nil { + t.Fatalf("runCodeScriptsList returned error: %v", err) + } + return + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/status": + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": true}) + case "/api/v1/code/scripts": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]interface{}{ + "dir": "/daemon/scripts", + "scripts": []map[string]interface{}{ + {"name": "from-daemon", "paths": []string{"/daemon/scripts/from-daemon.js"}, "status": "ok"}, + }, + }, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + // The local scripts directory holds a DIFFERENT name, so a listing that + // quietly resolved locally instead of asking the daemon is visible. + dir := t.TempDir() + cfg, err := json.Marshal(map[string]interface{}{"listen": "127.0.0.1:0", "data_dir": dir}) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "mcp_config.json"), cfg, 0o600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + if err := os.MkdirAll(filepath.Join(dir, "scripts"), 0o755); err != nil { + t.Fatalf("failed to create scripts dir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "scripts", "only-local.js"), []byte("1"), 0o600); err != nil { + t.Fatalf("failed to write script: %v", err) + } + + cmd := exec.Command(os.Args[0], "-test.run=^TestRunCodeScriptsList_DaemonPath$", "-test.timeout=60s") + cmd.Env = append(os.Environ(), + codeScriptsListChildEnv+"=1", + codeScriptsListChildEnv+"_DIR="+dir, + "MCPPROXY_TRAY_ENDPOINT="+srv.URL, + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("code scripts list failed against a daemon (%v)\nchild output:\n%s", err, out) + } + if !strings.Contains(string(out), "from-daemon") { + t.Fatalf("the listing did not come from the daemon\nchild output:\n%s", out) + } + if strings.Contains(string(out), "only-local") { + t.Fatalf("the listing was resolved locally while a daemon was running\nchild output:\n%s", out) + } +} + +// TestRunCodeScriptsList_JSONShape (FR-007) pins the machine-readable listing +// agents consume. The human rendering is covered above and the REST twin is +// pinned in internal/httpapi; without this, renaming a payload key or dropping +// the directory would keep the whole suite green while breaking the contract +// the -o json / MCPPROXY_OUTPUT branch exists to provide. +func TestRunCodeScriptsList_JSONShape(t *testing.T) { + if os.Getenv(codeScriptsListJSONChildEnv) == "1" { + dir := os.Getenv(codeScriptsListJSONChildEnv + "_DIR") + codeConfigPath = filepath.Join(dir, "mcp_config.json") + t.Cleanup(func() { codeConfigPath = "" }) + if err := runCodeScriptsList(codeScriptsListCmd, nil); err != nil { + t.Fatalf("runCodeScriptsList returned error: %v", err) + } + return + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/status": + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": true}) + case "/api/v1/code/scripts": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]interface{}{ + "dir": "/daemon/scripts", + "scripts": []map[string]interface{}{ + {"name": "from-daemon", "paths": []string{"/daemon/scripts/from-daemon.js"}, "status": "ok"}, + {"name": "blank", "paths": []string{"/daemon/scripts/blank.js"}, "status": "invalid", "reason": "empty"}, + }, + }, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + dir := t.TempDir() + cfg, err := json.Marshal(map[string]interface{}{"listen": "127.0.0.1:0", "data_dir": dir}) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "mcp_config.json"), cfg, 0o600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cmd := exec.Command(os.Args[0], "-test.run=^TestRunCodeScriptsList_JSONShape$", "-test.timeout=60s") + cmd.Env = append(os.Environ(), + codeScriptsListJSONChildEnv+"=1", + codeScriptsListJSONChildEnv+"_DIR="+dir, + "MCPPROXY_TRAY_ENDPOINT="+srv.URL, + "MCPPROXY_OUTPUT=json", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("code scripts list -o json failed (%v)\nchild output:\n%s", err, out) + } + + var payload struct { + Dir string `json:"dir"` + Scripts []codescripts.Entry `json:"scripts"` + } + if err := json.Unmarshal([]byte(jsonObjectFrom(t, string(out))), &payload); err != nil { + t.Fatalf("the listing is not machine-readable JSON (%v)\nchild output:\n%s", err, out) + } + + if payload.Dir != "/daemon/scripts" { + t.Fatalf("payload.dir = %q, want the directory the daemon reported", payload.Dir) + } + if len(payload.Scripts) != 2 { + t.Fatalf("payload.scripts = %+v, want 2 entries", payload.Scripts) + } + if payload.Scripts[0].Name != "from-daemon" || payload.Scripts[0].Status != codescripts.StatusOK { + t.Fatalf("scripts[0] = %+v", payload.Scripts[0]) + } + if len(payload.Scripts[0].Paths) != 1 || payload.Scripts[0].Paths[0] != "/daemon/scripts/from-daemon.js" { + t.Fatalf("scripts[0].paths = %+v", payload.Scripts[0].Paths) + } + if payload.Scripts[1].Status != codescripts.StatusInvalid || payload.Scripts[1].Reason != codescripts.ReasonEmpty { + t.Fatalf("scripts[1] = %+v, want the invalid status and its reason", payload.Scripts[1]) + } +} + +// jsonObjectFrom extracts the JSON object from child output that may also carry +// log lines. +func jsonObjectFrom(t *testing.T, out string) string { + t.Helper() + start := strings.Index(out, "{") + end := strings.LastIndex(out, "}") + if start < 0 || end < start { + t.Fatalf("no JSON object in output:\n%s", out) + } + return out[start : end+1] +} + +const codeScriptsListFallbackEnv = "MCPPROXY_TEST_CODE_SCRIPTS_LIST_FALLBACK" + +// TestRunCodeScriptsList_LocalFallbackIsAnnounced covers the case where the +// command cannot even load a config: it then read the default scripts directory +// and printed "No stored scripts in ~/.mcpproxy/scripts" as if that were the +// answer. A daemon started with --config elsewhere is the process that actually +// resolves scripts, so a silent local listing can contradict it outright. The +// local answer is still given — it is the best available — but it says so. +func TestRunCodeScriptsList_LocalFallbackIsAnnounced(t *testing.T) { + if os.Getenv(codeScriptsListFallbackEnv) == "1" { + codeConfigPath = filepath.Join(os.Getenv(codeScriptsListFallbackEnv+"_DIR"), "mcp_config.json") + t.Cleanup(func() { codeConfigPath = "" }) + if err := runCodeScriptsList(codeScriptsListCmd, nil); err != nil { + t.Fatalf("runCodeScriptsList returned error: %v", err) + } + return + } + + // No config file is written here, so loadCodeConfig fails; the endpoint + // refuses connections, so no daemon is reachable either. + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "scripts"), 0o755); err != nil { + t.Fatalf("failed to create scripts dir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "scripts", "local-only.js"), []byte("1"), 0o600); err != nil { + t.Fatalf("failed to write script: %v", err) + } + + cmd := exec.Command(os.Args[0], "-test.run=^TestRunCodeScriptsList_LocalFallbackIsAnnounced$", "-test.timeout=60s") + cmd.Env = append(os.Environ(), + codeScriptsListFallbackEnv+"=1", + codeScriptsListFallbackEnv+"_DIR="+dir, + "MCPPROXY_TRAY_ENDPOINT=http://127.0.0.1:1", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("code scripts list failed without a config (%v)\nchild output:\n%s", err, out) + } + text := string(out) + if !strings.Contains(text, "local-only") { + t.Fatalf("the local listing must still be produced\nchild output:\n%s", text) + } + if !strings.Contains(text, "could not load") || !strings.Contains(text, "local") { + t.Fatalf("a local-only listing must say so, or it silently contradicts a running daemon\nchild output:\n%s", text) + } + + // The warning is the consolation prize, not the fix: when a daemon IS + // reachable, an unloadable config must not cost the authoritative answer. + // Socket detection never needed the config in the first place. + t.Run("a reachable daemon still answers", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/status": + _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": true}) + case "/api/v1/code/scripts": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]interface{}{ + "dir": "/etc/mcpproxy/scripts", + "scripts": []map[string]interface{}{ + {"name": "from-daemon", "paths": []string{"/etc/mcpproxy/scripts/from-daemon.js"}, "status": "ok"}, + }, + }, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + cmd := exec.Command(os.Args[0], "-test.run=^TestRunCodeScriptsList_LocalFallbackIsAnnounced$", "-test.timeout=60s") + cmd.Env = append(os.Environ(), + codeScriptsListFallbackEnv+"=1", + codeScriptsListFallbackEnv+"_DIR="+dir, + "MCPPROXY_TRAY_ENDPOINT="+srv.URL, + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("code scripts list failed against a daemon without a config (%v)\nchild output:\n%s", err, out) + } + text := string(out) + if !strings.Contains(text, "from-daemon") { + t.Fatalf("an unloadable config must not hide a reachable daemon\nchild output:\n%s", text) + } + if strings.Contains(text, "local-only") { + t.Fatalf("the listing was resolved locally while a daemon was running\nchild output:\n%s", text) + } + }) +} + +const codeScriptNotFoundChildEnv = "MCPPROXY_TEST_CODE_SCRIPT_NOTFOUND_CHILD" + +// TestRunCodeExecStandalone_ScriptNotFoundReportsAvailable pins that the +// not-found error reaches the user in standalone mode. That error IS the +// discovery mechanism (FR-004): it carries the available script names, so +// reporting "unexpected result format" instead of the tool's own text leaves a +// mistyped name with no way back. +func TestRunCodeExecStandalone_ScriptNotFoundReportsAvailable(t *testing.T) { + if os.Getenv(codeScriptNotFoundChildEnv) == "1" { + codeTimeout = 20000 + codeMaxToolCalls = 0 + codeAllowedSrvs = nil + codeLanguage = "javascript" + codeLanguageExplicit = false + codeScriptName = "nope" + codeConfigPath = filepath.Join(os.Getenv(codeScriptNotFoundChildEnv+"_DIR"), "mcp_config.json") + + cfg, err := loadCodeConfig() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + _ = runCodeExecStandalone(cfg, "", map[string]interface{}{}, zap.NewNop()) + return + } + + dir := t.TempDir() + cfg, err := json.Marshal(map[string]interface{}{ + "listen": "127.0.0.1:0", + "data_dir": dir, + "enable_code_execution": true, + "code_execution_pool_size": 1, + }) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "mcp_config.json"), cfg, 0o600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + scriptsDir := filepath.Join(dir, "scripts") + if err := os.MkdirAll(scriptsDir, 0o755); err != nil { + t.Fatalf("failed to create scripts dir: %v", err) + } + if err := os.WriteFile(filepath.Join(scriptsDir, "double.js"), []byte("({result: 1})"), 0o600); err != nil { + t.Fatalf("failed to write script: %v", err) + } + + cmd := exec.Command(os.Args[0], "-test.run=^TestRunCodeExecStandalone_ScriptNotFoundReportsAvailable$", "-test.timeout=120s") + cmd.Env = append(os.Environ(), + codeScriptNotFoundChildEnv+"=1", + codeScriptNotFoundChildEnv+"_DIR="+dir, + ) + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("naming a script that does not exist must fail\nchild output:\n%s", out) + } + if !strings.Contains(string(out), "double") { + t.Fatalf("the not-found error must list the available scripts\nchild output:\n%s", out) + } +} diff --git a/docs/code_execution/api-reference.md b/docs/code_execution/api-reference.md index bb4f755e6..b9c56c15f 100644 --- a/docs/code_execution/api-reference.md +++ b/docs/code_execution/api-reference.md @@ -9,8 +9,9 @@ Complete reference for the `code_execution` MCP tool (JavaScript and TypeScript) 3. [Response Format](#response-format) 4. [JavaScript API](#javascript-api) 5. [Error Codes](#error-codes) -6. [Configuration](#configuration) -7. [CLI Reference](#cli-reference) +6. [Stored Scripts](#stored-scripts) +7. [Configuration](#configuration) +8. [CLI Reference](#cli-reference) --- @@ -29,6 +30,10 @@ Complete reference for the `code_execution` MCP tool (JavaScript and TypeScript) "type": "string", "description": "JavaScript or TypeScript source code (ES2020+) to execute..." }, + "script": { + "type": "string", + "description": "Name of a stored script to execute instead of sending `code` inline. Bare name (1-64 chars of A-Za-z0-9_-), never a path; resolved from the scripts/ directory next to the active config file..." + }, "language": { "type": "string", "description": "Source code language. When set to 'typescript', the code is automatically transpiled to JavaScript before execution.", @@ -62,12 +67,15 @@ Complete reference for the `code_execution` MCP tool (JavaScript and TypeScript) } } } - }, - "required": ["code"] + } } } ``` +Neither `code` nor `script` is `required`: exactly one of them must be supplied, +a rule JSON Schema cannot express. The tool enforces it and rejects a call +carrying both or neither. See [Stored Scripts](#stored-scripts). + --- ## Request Format @@ -93,6 +101,18 @@ Complete reference for the `code_execution` MCP tool (JavaScript and TypeScript) } ``` +### Stored-Script Request + +```json +{ + "script": "fetch-prs", + "input": { + "owner": "acme", + "repo": "api" + } +} +``` + ### Full Request with Options ```json @@ -113,8 +133,9 @@ Complete reference for the `code_execution` MCP tool (JavaScript and TypeScript) | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `code` | string | **Yes** | JavaScript or TypeScript source code to execute (ES2020+ syntax supported) | -| `language` | string | No | Source language: `"javascript"` (default) or `"typescript"` | +| `code` | string | **Exactly one of** `code` / `script` | JavaScript or TypeScript source code to execute (ES2020+ syntax supported) | +| `script` | string | **Exactly one of** `code` / `script` | Name of a [stored script](#stored-scripts) to execute — a bare name, never a path | +| `language` | string | No | Source language: `"javascript"` (default) or `"typescript"`. For a stored script the extension decides, and a contradicting value is an error | | `input` | object | No | Input data accessible as `input` global variable (default: `{}`) | | `options` | object | No | Execution options (see below) | @@ -527,6 +548,139 @@ var isObject = typeof value === 'object' && value !== null; --- +## Stored Scripts + +A stored script is a `.js` / `.ts` file in the `scripts/` directory +next to the **active configuration file** (`~/.mcpproxy/scripts/` by default, +`/scripts/` when `--config` names another file). Callers +address it by base name via the `script` parameter; the code_execution tool is +the only component that resolves a name to a file, on every surface. + +### File Rules + +| Rule | Value | +|------|-------| +| Name | 1-64 characters of `A-Za-z0-9_-`, case-sensitive; validated before any filesystem access | +| Path | never accepted — separators, `..`, dots, absolute paths and non-ASCII are invalid names | +| Extension | lowercase `.js` or `.ts` only | +| Language | derived from the extension (`.js` → `javascript`, `.ts` → `typescript`) | +| Size | 1 byte to 262144 bytes (256 KB); empty and oversized files are rejected | +| File type | regular file; symlinks, directories and devices are rejected (`O_NOFOLLOW` on Unix, checked policy on Windows) | +| Ambiguity | `.js` and `.ts` both present → the invocation fails naming both paths | + +Each invocation performs exactly one open and one bounded read — no cache, no +watcher — so an atomic replacement (write temp + `rename`) takes effect on the +next invocation with no daemon restart. Additions and deletions likewise. + +Execution is identical to inline code in every other respect: sandbox +restrictions, `allowed_servers`, `max_tool_calls`, `timeout_ms`, quarantine and +permission enforcement, and activity/history records — which keep storing the +executed source under `code` and additionally carry `script: ""`. + +### Invocation Errors + +| Situation | Message (abbreviated) | +|-----------|-----------------------| +| Both or neither of `code` / `script` | `Provide exactly one of 'code' (inline source) or 'script' (the name of a script stored in the 'scripts' directory next to mcpproxy's config file) — not both, not neither.` | +| Unknown name | `stored script "X" not found in . Available scripts (N): a, b, c …` | +| No scripts at all | `stored script "X" not found: no stored scripts in (create X.js or X.ts there)` | +| Invalid name | `invalid script name "…": character "/" is not allowed …` | +| Both extensions present | `stored script "X" is ambiguous: /X.js and /X.ts both exist — remove one` | +| Empty / oversized / unreadable / non-regular | `stored script "X" () is oversized: scripts are limited to 262144 bytes` | +| `language` contradicts the extension | `stored script "X" is a .ts file (typescript) but language "javascript" was requested …` | + +The not-found error **is** the MCP discovery mechanism (FR-004): it lists the +first 20 `ok` names alphabetically plus the total count, so an agent recovers +the current name set from one failed call. Tool registrations are static — there +is no listing tool and no `tools/list_changed` notification. + +### REST: `POST /api/v1/code/exec` + +The request body gains an optional `script` field, mutually exclusive with +`code`: + +```bash +curl -X POST http://127.0.0.1:8080/api/v1/code/exec \ + -H "Content-Type: application/json" \ + -H "X-API-Key: $MCPPROXY_API_KEY" \ + -d '{"script": "fetch-prs", "input": {"owner": "acme", "repo": "api"}}' +``` + +Supplying both or neither is answered as **HTTP 400** in the endpoint's own +envelope, before anything is dispatched: + +```json +{ + "ok": false, + "error": { + "code": "INVALID_REQUEST", + "message": "Provide exactly one of 'code' (inline source) or 'script' (the name of a stored script)" + }, + "request_id": "…" +} +``` + +The remaining refusals carry the tool's own explanation and a status a client +can act on. Only a genuine execution fault is a 500, so an agent's retry policy +never re-sends a request that cannot succeed: + +| Situation | Status | `error.code` | +|-----------|--------|--------------| +| `enable_code_execution` is `false` | 403 | `FEATURE_DISABLED` | +| Unknown script name (carries the available names) | 404 | `SCRIPT_NOT_FOUND` | +| Invalid script name | 400 | `INVALID_SCRIPT_NAME` | +| Ambiguous, empty, oversized, unreadable or non-regular | 400 | `SCRIPT_UNUSABLE` | +| `language` contradicts the extension | 400 | `INVALID_LANGUAGE` | +| Execution fault (pool, storage, internal) | 500 | `EXECUTION_FAILED` | + +A script that RUNS and throws is not a refusal: that is still `HTTP 200` with +`ok: false` and a `RUNTIME_ERROR` in the envelope. + +`enable_code_execution: false` is enforced for every caller, not just MCP ones — +the check sits in the tool handler that REST, the CLI and the tray all reach, so +switching the feature off also stops stored scripts from being read from disk. + +### REST: `GET /api/v1/code/scripts` + +Read-only listing of the stored scripts, using the same API-key auth as the rest +of `/api/v1` (`X-API-Key` header or `?apikey=`): + +```bash +curl -H "X-API-Key: $MCPPROXY_API_KEY" http://127.0.0.1:8080/api/v1/code/scripts +``` + +```json +{ + "success": true, + "data": { + "dir": "/Users/me/.mcpproxy/scripts", + "scripts": [ + {"name": "daily-report", "paths": ["/Users/me/.mcpproxy/scripts/daily-report.ts"], "status": "ok"}, + {"name": "fetch-prs", "paths": ["/Users/me/.mcpproxy/scripts/fetch-prs.js"], "status": "ok"}, + {"name": "half-written", "paths": ["/Users/me/.mcpproxy/scripts/half-written.js"], "status": "invalid", "reason": "empty"}, + {"name": "triage", "paths": ["/Users/me/.mcpproxy/scripts/triage.js", + "/Users/me/.mcpproxy/scripts/triage.ts"], "status": "ambiguous"} + ] + } +} +``` + +| Field | Description | +|-------|-------------| +| `dir` | The directory that was read — always reported, so "no scripts" and "not the directory you meant" are distinguishable | +| `name` | Token-valid base name | +| `paths` | One source path, or both candidates when `status` is `ambiguous` | +| `status` | `ok` (invocable), `ambiguous`, or `invalid` | +| `reason` | Present for `invalid`: `empty`, `oversized`, `unreadable`, or `non-regular` | + +An absent or empty directory returns an empty `scripts` list, not an error. +Statuses are advisory — the tool re-checks at invocation time. + +**There is no write surface.** No endpoint, tool, or CLI verb creates, updates, +or deletes a script; the filesystem is the sole authoring interface. + +--- + ## Configuration ### Global Configuration @@ -592,15 +746,22 @@ mcpproxy code exec [flags] | Flag | Type | Default | Description | |------|------|---------|-------------| -| `--code` | string | | JavaScript code to execute (required if `--file` not provided) | -| `--file` | string | | Path to JavaScript file (required if `--code` not provided) | +| `--code` | string | | Inline JavaScript/TypeScript code to execute | +| `--file` | string | | Path to a local JavaScript/TypeScript file, read by the CLI | +| `--script` | string | | Name of a [stored script](#stored-scripts) resolved server-side | | `--input` | string | `"{}"` | Input data as JSON string | | `--input-file` | string | | Path to JSON file containing input data | | `--timeout` | int | `120000` | Execution timeout in milliseconds (1-600000) | | `--max-tool-calls` | int | `0` | Maximum tool calls (0 = unlimited) | | `--allowed-servers` | []string | `[]` | Comma-separated list of allowed server names | | `--log-level` | string | `"info"` | Log level (trace, debug, info, warn, error) | -| `--config` | string | `~/.mcpproxy/mcp_config.json` | Path to MCP configuration file | +| `--config` | string | `~/.mcpproxy/mcp_config.json` | Path to MCP configuration file (also decides which `scripts/` directory is used) | + +Exactly one of `--code`, `--file`, `--script` must be given; combining them is +rejected with exit code 2. `--script` sends the **name** in both daemon and +standalone mode — the content never crosses the wire, and only the handler +resolves it. `--language` is forwarded only when you actually set it, so its +`javascript` default cannot contradict a stored `.ts` script. #### Exit Codes @@ -619,6 +780,9 @@ mcpproxy code exec --code="({ result: input.value * 2 })" --input='{"value": 21} # Code from file mcpproxy code exec --file=script.js --input-file=params.json +# Stored script, resolved server-side by name +mcpproxy code exec --script=fetch-prs --input='{"owner":"acme","repo":"api"}' + # Call upstream tools mcpproxy code exec --code="call_tool('github', 'get_user', {username: input.user})" --input='{"user":"octocat"}' @@ -686,17 +850,54 @@ EOF mcpproxy code exec --file=/tmp/script.js ``` +### Command: `mcpproxy code scripts list` + +List the [stored scripts](#stored-scripts) the code_execution tool can run. + +```bash +mcpproxy code scripts list +mcpproxy code scripts list -o json +mcpproxy code scripts list --config /etc/mcpproxy/mcp_config.json +``` + +When a daemon is running the CLI asks it (`GET /api/v1/code/scripts`) — the +process that actually resolves scripts describes itself, so the listing can +never disagree with what executes. Without a daemon the local scripts directory +is read directly. + +```text +Stored scripts in /Users/me/.mcpproxy/scripts (3): + daily-report ok /Users/me/.mcpproxy/scripts/daily-report.ts + fetch-prs ok /Users/me/.mcpproxy/scripts/fetch-prs.js + triage ambiguous /Users/me/.mcpproxy/scripts/triage.js, /Users/me/.mcpproxy/scripts/triage.ts + +Run one with: mcpproxy code exec --script +``` + +`-o json` / `-o yaml` emit `{"dir": …, "scripts": [ … ]}`, the same shape the +REST endpoint returns. There is deliberately no command that writes a script. + --- ## Validation Rules -### Code Validation +### Source Validation -- **Required**: `code` parameter must be provided -- **Type**: Must be a string -- **Syntax**: Must be valid JavaScript (ES2020+ supported) +- **Exactly one of** `code` (inline source) or `script` (a stored script name) + must be provided; both or neither is rejected before execution +- **Type**: Both must be strings +- **Syntax**: `code` must be valid JavaScript (ES2020+ supported) - **Serialization**: Return value must be JSON-serializable +### Script Name Validation + +- **Token**: 1-64 characters of `A-Za-z0-9_-`, checked before any filesystem + access — a name is never a path +- **File**: `.js` or `.ts` (lowercase), a regular file of 1 byte to + 256 KB; both extensions present is ambiguous and rejected +- **Language**: derived from the extension; an explicit `language` that + contradicts it is rejected + ### Input Validation - **Type**: Must be a valid JSON object diff --git a/docs/code_execution/cookbook.md b/docs/code_execution/cookbook.md index f26d142f9..6a34cd075 100644 --- a/docs/code_execution/cookbook.md +++ b/docs/code_execution/cookbook.md @@ -31,7 +31,8 @@ annotations and omit `language`. 1. [When to reach for the cookbook](#when-to-reach-for-the-cookbook) 2. [The sandbox contract (read this first)](#the-sandbox-contract-read-this-first) -3. Recipes +3. [Store a recipe as a script](#store-a-recipe-as-a-script) +4. Recipes - [Recipe 1 — Batch call (one tool, many inputs)](#recipe-1--batch-call-one-tool-many-inputs) - [Recipe 2 — Fan‑out + merge (many tools, one object)](#recipe-2--fan-out--merge-many-tools-one-object) - [Recipe 3 — Sequential pipeline (chain calls)](#recipe-3--sequential-pipeline-chain-calls) @@ -42,8 +43,8 @@ annotations and omit `language`. - [Recipe 8 — Cursor / pagination walk](#recipe-8--cursor--pagination-walk) - [Recipe 9 — Rate‑limit via chunking](#recipe-9--rate-limit-via-chunking) - [Recipe 10 — Deduplicate + enrich](#recipe-10--deduplicate--enrich) -4. [Benchmarks — token & latency](#benchmarks--token--latency) -5. [Upgrade note — TypeScript GA](#upgrade-note-typescript-ga) +5. [Benchmarks — token & latency](#benchmarks--token--latency) +6. [Upgrade note — TypeScript GA](#upgrade-note-typescript-ga) --- @@ -98,6 +99,51 @@ with `call_tools(requests, {max_parallel})` (1–32). --- +## Store a recipe as a script + +Once a recipe below has settled into a workflow you run repeatedly, stop paying +for its source on every call. Save it as a **stored script** — a `.ts` / +`.js` file in the `scripts/` directory next to mcpproxy's active config +file — and invoke it by name: + +```bash +mkdir -p ~/.mcpproxy/scripts +cp recipe-1.ts ~/.mcpproxy/scripts/user-lookup.ts # the recipe BODY, no annotation lines +mcpproxy code scripts list +mcpproxy code exec --script user-lookup --input='{"usernames":["octocat","torvalds"]}' +``` + +```json +{"script": "user-lookup", "input": {"usernames": ["octocat", "torvalds"]}} +``` + +Things to know when converting a recipe: + +- **Store the body only.** The `// language:` / `// input:` lines are + annotations for this document. The **file extension** replaces the `language` + parameter (`.ts` → TypeScript, `.js` → JavaScript), and `input` stays a + request parameter — pass it per invocation, exactly as inline. +- **Nothing else changes.** Same sandbox contract, same `options` + (`max_tool_calls`, `timeout_ms`), same `call_tools` batching semantics, same + activity records. `script` only changes where the source text came from. +- **Name it like a token**: 1–64 characters of `A-Za-z0-9_-`, never a path. + Files up to 256 KB; both `name.js` and `name.ts` present is ambiguous and + rejected. +- **Edit by atomic replace** (write a temp file, `mv` it over) and the next + invocation runs the new content — no daemon restart, so the authoring loop is + still "edit, rerun". +- **Discovery** is the not‑found error: naming a script that does not exist + returns the available names (first 20 alphabetically, plus the total), so an + agent never needs the list out of band. `mcpproxy code scripts list` shows the + full set, including `ambiguous` and `invalid` entries. +- **Read‑only surface**: nothing writes scripts for you — no tool, no endpoint, + no CLI verb. Authoring is the filesystem, deliberately. + +Full reference: [overview.md § Stored Scripts](overview.md#stored-scripts) · +[api-reference.md § Stored Scripts](api-reference.md#stored-scripts). + +--- + ## Recipe 1 — Batch call (one tool, many inputs) **Problem:** Call the same tool once per item in a list and collect the results. diff --git a/docs/code_execution/overview.md b/docs/code_execution/overview.md index caaf3ab1f..9e9d76ffd 100644 --- a/docs/code_execution/overview.md +++ b/docs/code_execution/overview.md @@ -6,6 +6,8 @@ The `code_execution` tool enables LLM agents to orchestrate multiple upstream MC **TypeScript support**: Set `language: "typescript"` to write code with type annotations, interfaces, enums, and generics. Types are automatically stripped before execution with near-zero overhead (<5ms). +**Stored scripts**: Instead of sending the source inline on every call, keep the workflow in `/scripts/.js` and invoke it with `script: ""`. See [Stored Scripts](#stored-scripts). + ## When to Use Code Execution ✅ **Use code_execution when:** @@ -132,14 +134,15 @@ Transform, filter, and aggregate data from multiple tool calls before returning ### Execution Flow -1. **Request Parsing**: Extract `code`, `input`, and `options` from the request -2. **Validation**: Verify timeout (1-600000ms) and max_tool_calls (>= 0) -3. **Pool Acquisition**: Acquire a JavaScript VM from the pool (blocks if all VMs are in use) -4. **Sandbox Setup**: Create isolated environment with `input` global and the `call_tool()` / `call_tools()` functions -5. **Execution**: Run JavaScript with timeout enforcement and tool call tracking -6. **Result Extraction**: Validate result is JSON-serializable and return structured response -7. **Pool Release**: Return VM to pool for reuse -8. **Response**: Return `{ok: true, value: }` or `{ok: false, error: {...}}` +1. **Request Parsing**: Extract `code` (or `script`), `input`, and `options` from the request +2. **Source Resolution**: Enforce exactly-one-of `code` / `script`; for a `script`, read the stored file and derive its language (see [Stored Scripts](#stored-scripts)) +3. **Validation**: Verify timeout (1-600000ms) and max_tool_calls (>= 0) +4. **Pool Acquisition**: Acquire a JavaScript VM from the pool (blocks if all VMs are in use) +5. **Sandbox Setup**: Create isolated environment with `input` global and the `call_tool()` / `call_tools()` functions +6. **Execution**: Run JavaScript with timeout enforcement and tool call tracking +7. **Result Extraction**: Validate result is JSON-serializable and return structured response +8. **Pool Release**: Return VM to pool for reuse +9. **Response**: Return `{ok: true, value: }` or `{ok: false, error: {...}}` ## Security Model @@ -247,15 +250,142 @@ The `code_execution` tool will appear in the tools list when an LLM agent connec "type": "object", "properties": { "code": {"type": "string", "description": "JavaScript or TypeScript source code..."}, + "script": {"type": "string", "description": "Name of a STORED script to execute instead of sending code inline (Spec 097)..."}, "language": {"type": "string", "enum": ["javascript", "typescript"], "description": "Source language; defaults to javascript. TypeScript types are stripped before execution (GA, Spec 033 FR-001)."}, "input": {"type": "object", "description": "Input data accessible as global input variable..."}, "options": {"type": "object", "description": "Execution options..."} - }, - "required": ["code"] + } } } ``` +Neither `code` nor `script` is schema-`required`: JSON Schema cannot express +"exactly one of", so the tool enforces it and rejects a call that supplies both +or neither. + +## Stored Scripts + +Sending a long workflow inline costs its full token count on every run, retry, +and parameter tweak. A **stored script** is that workflow kept on the server — +a `.js` / `.ts` file in the `scripts/` directory next to the active +configuration file — invoked by name: + +```json +{ + "name": "code_execution", + "arguments": { + "script": "fetch-prs", + "input": {"owner": "acme", "repo": "api"} + } +} +``` + +Everything else is identical to an inline call: same sandbox, same +`allowed_servers` / `max_tool_calls` / `timeout_ms` handling, same quarantine and +permission enforcement, same activity and history records (which store the +executed source exactly as they do for inline code, plus the script name). +`script` changes only where the source text comes from. + +### 1. Author a script + +```bash +mkdir -p ~/.mcpproxy/scripts +cat > ~/.mcpproxy/scripts/fetch-prs.js <<'JS' +var rs = call_tools([1, 2, 3].map(function (n) { + return {server: "github", tool: "get_pull_request", + args: {owner: input.owner, repo: input.repo, pullNumber: n}}; +})); +({titles: rs.map(function (r) { return r.ok ? JSON.parse(r.result.content[0].text).title : "ERR"; })}); +JS +``` + +The directory is derived from the **active config file**, not from `--data-dir`: +with the default `~/.mcpproxy/mcp_config.json` it is `~/.mcpproxy/scripts/`, and +with `--config /etc/mcpproxy/mcp_config.json` it is `/etc/mcpproxy/scripts/`. +mcpproxy never creates the directory itself — an absent one simply means "no +scripts". + +### 2. Run it + +```bash +mcpproxy code scripts list +mcpproxy code exec --script fetch-prs --input='{"owner":"acme","repo":"api"}' +``` + +`--script` is mutually exclusive with `--code` and `--file`. In both daemon and +standalone mode the CLI sends the **name**; the daemon (or the in-process +handler) is the only thing that resolves it, so every surface agrees on what a +name means. + +### Naming and file rules + +| Rule | Value | +|------|-------| +| Name | 1-64 characters of `A-Za-z0-9_-`, case-sensitive | +| Path | never — a name with a separator, `..`, or a dot is rejected before any filesystem access | +| Extension | lowercase `.js` or `.ts` only (`.JS`, `.mjs`, `.jsx` are not scripts) | +| Language | derived from the extension; an explicit `language` that contradicts it is an error | +| Size | 1 byte to 256 KB — empty and oversized files are rejected | +| File type | regular files only; a symlink at the script path is rejected | +| Ambiguity | `name.js` **and** `name.ts` both present → the call fails naming both | + +Files that break the name or extension rules are ignored by listings and +unreachable by invocation — they are not scripts. + +> **Confinement**: the name is validated *before* the filesystem is touched, so +> a valid name cannot traverse out of the scripts directory by construction. On +> top of that, the file is opened with symlink-following disabled (atomically on +> Unix via `O_NOFOLLOW`; a checked policy on Windows, where creating symlinks +> requires elevation). The scripts directory itself may be a symlink — it is +> operator-controlled. + +### Editing without a restart + +Each invocation performs exactly one open and one bounded read; there is no +cache and no file watcher, so there is nothing to invalidate. Edit by **atomic +replace** — write a temporary file and `rename` it over the script — and the +next invocation runs the new content: + +```bash +tmp=$(mktemp ~/.mcpproxy/scripts/.fetch-prs.XXXXXX) +cat > "$tmp" <<'JS' +({updated: true}); +JS +mv "$tmp" ~/.mcpproxy/scripts/fetch-prs.js # atomic within the same filesystem +``` + +Adding or deleting a file is reflected on the next invocation or listing. +Editing a script **in place** while it is being invoked is the one unsupported +case: the run gets whatever the read returned (validated, but unspecified). + +### Discovering script names + +```bash +mcpproxy code scripts list # human-readable, always names the directory it read +mcpproxy code scripts list -o json # {"dir": "...", "scripts": [{"name","paths","status"}]} +``` + +```bash +curl -H "X-API-Key: $KEY" http://127.0.0.1:8080/api/v1/code/scripts +``` + +MCP clients do not get a listing tool — registrations are static, so an embedded +list would go stale. Discovery is **error-driven** instead: invoking a name that +does not exist returns an error listing the first 20 available names +alphabetically plus the total, so an agent recovers the current name set from a +single failed call. + +```text +Cannot execute stored script: stored script "fetch-pr" not found in +/Users/me/.mcpproxy/scripts. Available scripts (3): daily-report, fetch-prs, triage +``` + +### No write path + +Nothing in mcpproxy creates, edits, or deletes a stored script: no MCP tool, no +REST endpoint, no CLI verb. The filesystem is the sole authoring interface, so +sandboxed code that can *run* a stored workflow can never author one. + ## Common Patterns ### Pattern 1: Sequential Tool Calls diff --git a/docs/code_execution/troubleshooting.md b/docs/code_execution/troubleshooting.md index da1b7cbaa..60361a473 100644 --- a/docs/code_execution/troubleshooting.md +++ b/docs/code_execution/troubleshooting.md @@ -9,9 +9,10 @@ Common issues, error messages, and solutions for the `code_execution` tool. 3. [Runtime Errors](#runtime-errors) 4. [Timeout Issues](#timeout-issues) 5. [Tool Call Errors](#tool-call-errors) -6. [Serialization Errors](#serialization-errors) -7. [Performance Issues](#performance-issues) -8. [Debugging Tips](#debugging-tips) +6. [Stored Script Errors](#stored-script-errors) +7. [Serialization Errors](#serialization-errors) +8. [Performance Issues](#performance-issues) +9. [Debugging Tips](#debugging-tips) --- @@ -563,6 +564,176 @@ the script's `timeout_ms` budget — size `queue_size` and `timeout_ms` together --- +## Stored Script Errors + +These apply to invocations that name a [stored script](overview.md#stored-scripts) +(`script: ""`, `--script `) instead of sending `code` inline. One +command answers most of them: + +```bash +mcpproxy code scripts list # names, paths, statuses, and the directory that was read +``` + +### Error: "Provide exactly one of 'code' or 'script'" + +**Symptom**: +``` +Provide exactly one of 'code' (inline source) or 'script' (the name of a script stored in the 'scripts' directory next to mcpproxy's config file) — not both, not neither. +``` + +Over REST the same rule is an HTTP 400 before dispatch: +```json +{"ok": false, "error": {"code": "INVALID_REQUEST", "message": "Provide exactly one of 'code' (inline source) or 'script' (the name of a stored script)"}} +``` + +**Cause**: The request carried both `code` and `script`, or neither. JSON Schema +cannot express "exactly one of", so neither field is schema-required and the +rule is enforced by the tool. + +**Solution**: Send one source. On the CLI, `--code`, `--file` and `--script` are +mutually exclusive (`--code, --file and --script are mutually exclusive`, exit +code 2). + +--- + +### Error: "stored script X not found" + +**Symptom**: +``` +Cannot execute stored script: stored script "fetch-pr" not found in /Users/me/.mcpproxy/scripts. Available scripts (3): daily-report, fetch-prs, triage +``` + +Or, with an empty/absent directory: +``` +Cannot execute stored script: stored script "fetch-pr" not found: no stored scripts in /Users/me/.mcpproxy/scripts (create fetch-pr.js or fetch-pr.ts there) +``` + +**Cause**: No `.js` / `.ts` in the scripts directory. Usually a typo +(names are **case-sensitive**), a file that is not a script (uppercase or other +extension: `.JS`, `.mjs`, `.jsx` are ignored), or the wrong directory — the +scripts directory follows the **active config file**, not `--data-dir`. + +**Solution**: This error *is* the discovery mechanism — it lists the first 20 +available names alphabetically plus the total, so an MCP client can recover the +name set from the failed call. For the full picture, including where the daemon +looked: +```bash +mcpproxy code scripts list +mcpproxy code scripts list --config /etc/mcpproxy/mcp_config.json # a non-default config +``` +If the directory in the message is not the one you authored in, start the daemon +with the config file you meant (`mcpproxy serve --config …`) — with +`~/.mcpproxy/mcp_config.json` the scripts live in `~/.mcpproxy/scripts/`. +mcpproxy never creates the directory itself; `mkdir -p` it. + +--- + +### Error: "invalid script name" + +**Symptom**: +``` +Cannot execute stored script: invalid script name "../../etc/passwd": character "." is not allowed (names are 1-64 characters of A-Z, a-z, 0-9, '-' or '_' — a name, never a path) +``` + +**Cause**: `script` is a **name**, never a path. Separators, `..`, dots, +extensions, non-ASCII characters and names longer than 64 characters are +rejected before the filesystem is touched at all. + +**Solution**: Pass the base name only — `fetch-prs`, not `fetch-prs.js`, +`./fetch-prs.js`, or `/abs/path/fetch-prs.js`. + +--- + +### Error: "stored script X is ambiguous" + +**Symptom**: +``` +Cannot execute stored script: stored script "triage" is ambiguous: /Users/me/.mcpproxy/scripts/triage.js and /Users/me/.mcpproxy/scripts/triage.ts both exist — remove one +``` + +**Cause**: Both extensions exist for one name — often a leftover after +converting a script from JavaScript to TypeScript. Ambiguity is never resolved +silently. + +**Solution**: Delete (or rename) one of the two files. `mcpproxy code scripts +list` flags such names with status `ambiguous` before you hit them at runtime. + +--- + +### Error: "stored script X is oversized / empty / unreadable / non-regular" + +**Symptom**: +``` +Cannot execute stored script: stored script "big-report" (/Users/me/.mcpproxy/scripts/big-report.js) is oversized: scripts are limited to 262144 bytes +``` + +**Cause**: + +| Reason | Meaning | +|--------|---------| +| `oversized` | The file exceeds the 256 KB stored-script bound (inline `code` has no such bound; this one exists purely to bound the daemon-side read) | +| `empty` | Zero bytes — commonly a half-finished redirect (`> script.js`) | +| `unreadable` | Permissions or an I/O error; the detail carries the OS error | +| `non-regular` | The path is a symlink, directory, or device — scripts must be regular files | + +**Solution**: Split an oversized workflow into several scripts (or move bulk +data into `input`), finish the write, fix permissions, or replace the symlink +with the real file. Copy, do not link: +```bash +cp /shared/workflows/report.js ~/.mcpproxy/scripts/report.js +``` +The scripts *directory* itself may be a symlink — it is operator-controlled; +only the script file may not be. + +--- + +### Error: "stored script X is a .ts file but language Y was requested" + +**Symptom**: +``` +Cannot execute stored script: stored script "daily-report" is a .ts file (typescript) but language "javascript" was requested — omit 'language' or set it to "typescript" +``` + +**Cause**: The extension is authoritative for a stored script, and the explicit +`language` contradicted it. + +**Solution**: Omit `language` entirely — the extension decides. (The CLI already +forwards `--language` only when you set it explicitly, so its `javascript` +default cannot trigger this.) + +--- + +### Issue: An edited script still runs the old content + +**Cause**: Almost always the file was not replaced where the daemon looks, or +the edit went to a different scripts directory. There is no cache and no +watcher: every invocation opens and reads the file once, so a completed +replacement is visible to the very next call — no restart, nothing to flush. + +**Solution**: Confirm the path with `mcpproxy code scripts list` (it always +prints the directory it read), then edit by **atomic replace** so no invocation +can observe a half-written file: +```bash +tmp=$(mktemp ~/.mcpproxy/scripts/.report.XXXXXX) +cp new-report.js "$tmp" && mv "$tmp" ~/.mcpproxy/scripts/report.js +``` +Editing **in place** while an invocation is reading is the one unsupported case: +that run gets whatever the read returned (validated, but unspecified). + +--- + +### Issue: No way to upload or edit a script through the API + +**Cause**: Working as designed. v1 has **no write path** for stored scripts — no +MCP tool, no REST endpoint, no CLI verb creates, updates, or deletes them. Code +running in the sandbox has no filesystem access either. + +**Solution**: Author scripts with your normal filesystem tooling (editor, `scp`, +configuration management). `GET /api/v1/code/scripts` and `mcpproxy code scripts +list` are read-only views of the result. + +--- + ## Serialization Errors ### Error: "Result contains non-JSON-serializable values" diff --git a/docs/configuration.md b/docs/configuration.md index eaba31ec6..73943e447 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1198,7 +1198,13 @@ var slots = call_tools([ > **Batching vs. per-server limits.** [Concurrency limits](#concurrency-limits--request-queueing) still govern each element. A server with `max_concurrent_requests` set and **no** `queue_size` sheds everything over the cap — a 10-element batch against `max_concurrent_requests: 1` returns 1 result and 9 per-slot `queue_full` errors. Give such servers `queue_size` headroom (or lower `max_parallel`) before fanning out against them. -See [Code Execution Documentation](code_execution/overview.md) for complete details. +### Stored Scripts + +Long workflows do not have to be re-sent inline on every call. A `.js` / `.ts` file placed in the `scripts/` directory **next to this configuration file** (`~/.mcpproxy/scripts/` by default, `/scripts/` when `--config` points elsewhere) is invocable by name — `{"script": "", "input": {...}}` over MCP/REST, or `mcpproxy code exec --script `. + +There is no configuration key for this: the directory convention is the whole surface, and the scripts directory is never derived from `--data-dir`. Names are 1-64 characters of `A-Za-z0-9_-` (never a path), files are lowercase `.js`/`.ts` up to 256 KB, and each invocation re-reads the file, so an atomic replacement takes effect on the next run with no restart. `mcpproxy code scripts list` (or `GET /api/v1/code/scripts`) lists what exists; nothing writes scripts through any API. + +See [Code Execution Documentation](code_execution/overview.md) for complete details, and [Stored Scripts](code_execution/overview.md#stored-scripts) for the authoring rules. --- diff --git a/docs/features/code-execution.md b/docs/features/code-execution.md index be3e789a8..686faf963 100644 --- a/docs/features/code-execution.md +++ b/docs/features/code-execution.md @@ -19,6 +19,7 @@ Code execution allows AI agents to: - Process and transform tool outputs - Implement complex logic and conditionals - Reduce round-trip latency +- Run [stored scripts](#stored-scripts) by name instead of re-sending the source every call ## Configuration @@ -62,18 +63,30 @@ mcpproxy code exec --language typescript --code="const x: number = 42; ({ result mcpproxy code exec --code="call_tool('github', 'get_user', {username: input.user})" --input='{"user":"octocat"}' ``` +### Stored Script + +```bash +mcpproxy code scripts list +mcpproxy code exec --script fetch-prs --input='{"owner":"acme","repo":"api"}' +``` + ## API ### Input Schema ```json { - "code": "string (required) - JavaScript or TypeScript code to execute", + "code": "string - inline JavaScript or TypeScript code to execute", + "script": "string - name of a stored script to execute instead of 'code'", "language": "string (optional) - 'javascript' (default) or 'typescript'", "input": "object (optional) - Input data available as 'input' variable" } ``` +Provide **exactly one** of `code` or `script` — both or neither is an error. +Neither is schema-required, because JSON Schema cannot express the rule; the +tool enforces it on every surface (MCP, REST, CLI). + ### Built-in Functions #### call_tool(server, tool, args) @@ -135,6 +148,73 @@ The last expression in the code is returned as the tool result: }) ``` +## Stored Scripts + +A stored script is a `.js` / `.ts` file the operator drops into the +`scripts/` directory next to the active config file. Agents then run it by name +with `script: ""` instead of re-sending the whole source on every call — a +19 KB workflow costs a name plus its `input` per run. + +```bash +mkdir -p ~/.mcpproxy/scripts +cat > ~/.mcpproxy/scripts/fetch-prs.js <<'JS' +var rs = call_tools([1, 2, 3].map(function (n) { + return {server: "github", tool: "get_pull_request", + args: {owner: input.owner, repo: input.repo, pullNumber: n}}; +})); +({titles: rs.map(function (r) { return r.ok ? JSON.parse(r.result.content[0].text).title : "ERR"; })}); +JS + +mcpproxy code scripts list +mcpproxy code exec --script fetch-prs --input='{"owner":"acme","repo":"api"}' +``` + +An MCP client runs the same script with +`{"script": "fetch-prs", "input": {"owner": "acme", "repo": "api"}}`. + +### Authoring rules + +| Rule | Value | +|------|-------| +| Location | `scripts/` next to the **active config file** (default `~/.mcpproxy/scripts/`) | +| Name | 1–64 characters of `A-Za-z0-9_-`, case-sensitive — a bare name, never a path | +| Extension | lowercase `.js` or `.ts` only; the extension decides the language | +| Size | 1 byte – 256 KB (empty and oversized files are rejected) | +| File type | regular files only — a symlink at the script path is rejected | + +`.ts` scripts take exactly the same transpilation path as inline +`language: "typescript"`. Passing a `language` that contradicts the extension is +an error; omitting it is the normal case. + +Both `name.js` and `name.ts` present is **ambiguous** — the invocation fails +naming both files rather than picking one. + +### Freshness + +Every invocation opens and reads the file once, with no cache and no watcher. +Edit a script by **atomic replace** (write a temp file, then rename over it) and +the next invocation runs the new content — no daemon restart. Added and removed +files are likewise picked up on next use. An in-place write racing an invocation +yields unspecified (but validated) content, which is why atomic replace is the +supported edit. + +### Discovery + +- **CLI / REST**: `mcpproxy code scripts list` (or `GET /api/v1/code/scripts`) + lists every name with its path and a status: `ok`, `ambiguous`, or `invalid` + (with a reason). Only `ok` scripts are invocable. +- **MCP clients**: discovery is error-driven. Invoking a name that does not + exist returns an error listing the first 20 available names alphabetically + plus the total count — the current name set is always one failed call away. + Tool registrations stay static; there is no listing tool and no + `tools/list_changed` notification. + +### No write path + +Nothing creates, edits, or deletes scripts through any API — no MCP tool, no +REST endpoint, no CLI verb. The filesystem is the only authoring interface, so +an agent can run stored workflows but never author them. + ## Examples ### Simple Calculation @@ -234,6 +314,15 @@ Verify the server and tool names: mcpproxy tools list --server=server-name ``` +### Stored Script Not Found + +The error already lists the available names. To see the full set — including +`ambiguous` and `invalid` entries and the directory that was read: + +```bash +mcpproxy code scripts list +``` + ## TypeScript Support Set `language: "typescript"` to write code with type annotations. TypeScript types are automatically stripped before execution using esbuild, with near-zero transpilation overhead (<5ms). diff --git a/internal/cliclient/client.go b/internal/cliclient/client.go index ab3e57ea3..13b4013a6 100644 --- a/internal/cliclient/client.go +++ b/internal/cliclient/client.go @@ -11,6 +11,7 @@ import ( "net/url" "time" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/codescripts" "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext" "github.com/smart-mcp-proxy/mcpproxy-go/internal/socket" @@ -226,6 +227,12 @@ func (c *Client) execHTTPClient() *http.Client { // CodeExecOptions contains optional parameters for code execution via the daemon API. type CodeExecOptions struct { Language string // Source language: "javascript" (default) or "typescript" + + // Script names a server-side stored script to execute instead of inline + // code (Spec 097). Only the NAME travels: the daemon's code_execution + // handler is the single execution-time resolver, so a stored script means + // the same thing over MCP, REST and both CLI modes. + Script string } // CodeExec executes JavaScript or TypeScript code via the daemon API. @@ -240,7 +247,6 @@ func (c *Client) CodeExec( ) (*CodeExecResult, error) { // Build request body reqBody := map[string]interface{}{ - "code": code, "input": input, "options": map[string]interface{}{ "timeout_ms": timeoutMS, @@ -249,8 +255,20 @@ func (c *Client) CodeExec( }, } - // Apply optional language parameter - if len(opts) > 0 && opts[0].Language != "" && opts[0].Language != "javascript" { + // Exactly one source travels (Spec 097): inline code, or the NAME of a + // stored script the daemon resolves. Sending an empty "code" alongside a + // script name would leave the caller's request ambiguous on the wire. + if len(opts) > 0 && opts[0].Script != "" { + reqBody["script"] = opts[0].Script + } else { + reqBody["code"] = code + } + + // Forward the language verbatim when the caller named one. Deciding + // whether the user MEANT it belongs to the caller (the CLI sends it only + // when --language was explicitly set); dropping an explicit "javascript" + // here would silently swallow a contradiction with a stored .ts script. + if len(opts) > 0 && opts[0].Language != "" { reqBody["language"] = opts[0].Language } @@ -286,6 +304,53 @@ func (c *Client) CodeExec( return &result, nil } +// GetCodeScripts lists the stored scripts the DAEMON can execute (Spec 097), +// together with the directory it read them from. Asking the daemon rather than +// listing locally is the point: a listing that disagreed with the process which +// actually resolves scripts would be worse than none. +func (c *Client) GetCodeScripts(ctx context.Context) (dir string, entries []codescripts.Entry, err error) { + url := c.baseURL + "/api/v1/code/scripts" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", nil, fmt.Errorf("failed to call stored scripts API: %w", err) + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var apiResp struct { + Success bool `json:"success"` + Data struct { + Scripts []codescripts.Entry `json:"scripts"` + Dir string `json:"dir"` + } `json:"data"` + Error string `json:"error"` + RequestID string `json:"request_id"` + } + + if err := json.Unmarshal(bodyBytes, &apiResp); err != nil { + return "", nil, fmt.Errorf("failed to parse response: %w", err) + } + + if !apiResp.Success { + return "", nil, parseAPIError(apiResp.Error, apiResp.RequestID) + } + + return apiResp.Data.Dir, apiResp.Data.Scripts, nil +} + // CallToolResult represents tool call result. type CallToolResult struct { Content []interface{} `json:"content"` diff --git a/internal/cliclient/code_exec_script_test.go b/internal/cliclient/code_exec_script_test.go new file mode 100644 index 000000000..41e43a2f6 --- /dev/null +++ b/internal/cliclient/code_exec_script_test.go @@ -0,0 +1,134 @@ +package cliclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// captureCodeExecBody stands up a daemon stub that records the JSON body of the +// code execution request and answers with a trivial success. +func captureCodeExecBody(t *testing.T, body *map[string]interface{}) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/code/exec" { + w.WriteHeader(http.StatusNotFound) + return + } + if err := json.NewDecoder(r.Body).Decode(body); err != nil { + t.Errorf("failed to decode request body: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "result": nil}) + })) +} + +// TestClient_CodeExec_SendsScriptName (Spec 097, T006) pins the stored-script +// wire contract: the CLI sends the script NAME and no source at all — the +// daemon's code_execution handler is the only execution-time resolver. +func TestClient_CodeExec_SendsScriptName(t *testing.T) { + var body map[string]interface{} + srv := captureCodeExecBody(t, &body) + defer srv.Close() + + result, err := NewClient(srv.URL, nil).CodeExec( + context.Background(), "", map[string]interface{}{}, 60000, 0, nil, + CodeExecOptions{Script: "daily-report"}, + ) + if err != nil { + t.Fatalf("CodeExec returned error: %v", err) + } + if !result.OK { + t.Fatalf("CodeExec result = %+v, want OK", result) + } + + if got := body["script"]; got != "daily-report" { + t.Fatalf("request body script = %v, want %q", got, "daily-report") + } + if code, present := body["code"]; present && code != "" { + t.Fatalf("a stored-script request must carry no inline code, got %q", code) + } +} + +// TestClient_CodeExec_LanguageForwardedVerbatim: the client is a dumb +// transport for `language`. Deciding whether the user meant it belongs to the +// CLI (which sends it only when --language was explicitly set); silently +// dropping an explicit "javascript" here would hide a contradiction with a +// stored .ts script instead of reporting it. +func TestClient_CodeExec_LanguageForwardedVerbatim(t *testing.T) { + for _, language := range []string{"javascript", "typescript"} { + t.Run(language, func(t *testing.T) { + var body map[string]interface{} + srv := captureCodeExecBody(t, &body) + defer srv.Close() + + _, err := NewClient(srv.URL, nil).CodeExec( + context.Background(), "({})", map[string]interface{}{}, 60000, 0, nil, + CodeExecOptions{Language: language}, + ) + if err != nil { + t.Fatalf("CodeExec returned error: %v", err) + } + if got := body["language"]; got != language { + t.Fatalf("request body language = %v, want %q", got, language) + } + }) + } + + t.Run("unset language is not sent", func(t *testing.T) { + var body map[string]interface{} + srv := captureCodeExecBody(t, &body) + defer srv.Close() + + _, err := NewClient(srv.URL, nil).CodeExec( + context.Background(), "({})", map[string]interface{}{}, 60000, 0, nil, + ) + if err != nil { + t.Fatalf("CodeExec returned error: %v", err) + } + if _, present := body["language"]; present { + t.Fatalf("request body must omit language when the caller set none: %v", body) + } + }) +} + +// TestClient_GetCodeScripts (Spec 097, T010) pins the daemon listing seam: the +// client unwraps the {success,data} envelope into the entries and the +// directory the daemon read them from. +func TestClient_GetCodeScripts(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/code/scripts" { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]interface{}{ + "dir": "/home/u/.mcpproxy/scripts", + "scripts": []map[string]interface{}{ + {"name": "alpha", "paths": []string{"/home/u/.mcpproxy/scripts/alpha.js"}, "status": "ok"}, + {"name": "blank", "paths": []string{"/home/u/.mcpproxy/scripts/blank.js"}, "status": "invalid", "reason": "empty"}, + }, + }, + }) + })) + defer srv.Close() + + dir, entries, err := NewClient(srv.URL, nil).GetCodeScripts(context.Background()) + if err != nil { + t.Fatalf("GetCodeScripts returned error: %v", err) + } + if dir != "/home/u/.mcpproxy/scripts" { + t.Fatalf("dir = %q", dir) + } + if len(entries) != 2 { + t.Fatalf("entries = %+v, want 2", entries) + } + if entries[0].Name != "alpha" || string(entries[0].Status) != "ok" { + t.Fatalf("entries[0] = %+v", entries[0]) + } + if entries[1].Reason != "empty" { + t.Fatalf("entries[1] = %+v, want reason empty", entries[1]) + } +} diff --git a/internal/codescripts/codescripts.go b/internal/codescripts/codescripts.go new file mode 100644 index 000000000..b2f047ee9 --- /dev/null +++ b/internal/codescripts/codescripts.go @@ -0,0 +1,442 @@ +// Package codescripts resolves and lists server-side stored scripts for the +// code_execution tool (Spec 097). +// +// A stored script is a file named `.js` or `.ts` in the `scripts/` +// directory next to the ACTIVE configuration file. Callers address it by base +// NAME — never by path — and this package is the single owner of what a name +// may be, how it maps to a file, and what a usable script file looks like. +// +// Confinement is the name validator, not the filesystem walk: a token-valid +// name contains no separators and no dots, so joining it to the scripts +// directory cannot escape that directory by construction (FR-003 / SC-003). +// ValidateName therefore runs before any filesystem call. On top of that +// boundary sits a symlink/non-regular POLICY, made atomic where the platform +// allows it (Unix O_NOFOLLOW) and best-effort where it does not (Windows). +// +// Nothing here caches: each Resolve performs one open and one bounded read, so +// an atomic replacement of a script file is visible to the very next +// invocation with nothing to invalidate (FR-009). +package codescripts + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +const ( + // MaxNameLen is the longest permitted script name. + MaxNameLen = 64 + + // MaxSizeBytes bounds the daemon-side read of a script file. Inline code + // has no such bound; stored scripts do, purely to bound the read. + MaxSizeBytes = 256 * 1024 + + // MaxErrorNames is how many available names a not-found error lists + // (FR-004); the total count is reported alongside. + MaxErrorNames = 20 + + // DirName is the scripts directory's name, relative to the config file. + DirName = "scripts" + + // LanguageJavaScript / LanguageTypeScript are the languages a script + // extension can derive, matching the code_execution `language` parameter. + LanguageJavaScript = "javascript" + LanguageTypeScript = "typescript" + + extJS = ".js" + extTS = ".ts" +) + +// Status classifies a listed script. +type Status string + +const ( + StatusOK Status = "ok" // invocable + StatusAmbiguous Status = "ambiguous" // both .js and .ts exist for this name + StatusInvalid Status = "invalid" // present but not usable; see Reason +) + +// Reasons a script file is present but unusable. +const ( + ReasonEmpty = "empty" + ReasonOversized = "oversized" + ReasonUnreadable = "unreadable" + ReasonNonRegular = "non-regular" +) + +// errNonRegular is the platform-independent signal that the path is not a +// regular file — a symlink, directory or device. Platform openers return it +// (Unix maps the kernel's no-follow rejection onto it). +var errNonRegular = errors.New("not a regular file") + +// Entry is one listed script (FR-007). Paths holds the single source file, or +// both candidates when the name is ambiguous. +type Entry struct { + Name string `json:"name"` + Paths []string `json:"paths"` + Status Status `json:"status"` + Reason string `json:"reason,omitempty"` +} + +// InvalidNameError rejects a script name before any filesystem access. +type InvalidNameError struct { + Name string + Reason string +} + +func (e *InvalidNameError) Error() string { + return fmt.Sprintf("invalid script name %q: %s (names are 1-%d characters of A-Z, a-z, 0-9, '-' or '_' — a name, never a path)", + truncateForMessage(e.Name), e.Reason, MaxNameLen) +} + +// NotFoundError reports a name with no script file behind it, carrying the +// available names so the caller can recover in one round trip (FR-004). +type NotFoundError struct { + Name string + Dir string + Available []string // first MaxErrorNames ok names, alphabetical + Total int // total ok scripts in the directory +} + +func (e *NotFoundError) Error() string { + if e.Total == 0 { + return fmt.Sprintf("stored script %q not found: no stored scripts in %s (create %s%s or %s%s there)", + e.Name, e.Dir, e.Name, extJS, e.Name, extTS) + } + msg := fmt.Sprintf("stored script %q not found in %s. Available scripts (%d): %s", + e.Name, e.Dir, e.Total, strings.Join(e.Available, ", ")) + if e.Total > len(e.Available) { + msg += fmt.Sprintf(" … and %d more (run 'mcpproxy code scripts list' for the full list)", e.Total-len(e.Available)) + } + return msg +} + +// AmbiguousError reports a name backed by both a .js and a .ts file. +type AmbiguousError struct { + Name string + Paths []string +} + +func (e *AmbiguousError) Error() string { + return fmt.Sprintf("stored script %q is ambiguous: %s both exist — remove one", + e.Name, strings.Join(e.Paths, " and ")) +} + +// InvalidError reports a script file that exists but cannot be executed. +type InvalidError struct { + Name string + Path string + Reason string + Detail string +} + +func (e *InvalidError) Error() string { + msg := fmt.Sprintf("stored script %q (%s) is %s", e.Name, e.Path, e.Reason) + switch e.Reason { + case ReasonOversized: + msg += fmt.Sprintf(": scripts are limited to %d bytes", MaxSizeBytes) + case ReasonNonRegular: + msg += ": only regular files are executed (symlinks, directories and devices are rejected)" + } + if e.Detail != "" { + msg += ": " + e.Detail + } + return msg +} + +// LanguageMismatchError reports an explicit `language` that contradicts the +// script's extension (the extension is authoritative). +type LanguageMismatchError struct { + Name string + Extension string + Requested string + Derived string +} + +func (e *LanguageMismatchError) Error() string { + return fmt.Sprintf("stored script %q is a %s file (%s) but language %q was requested — omit 'language' or set it to %q", + e.Name, e.Extension, e.Derived, e.Requested, e.Derived) +} + +// DirFor returns the scripts directory belonging to a config file path. +// An empty config path yields an empty directory (no authority, no scripts). +func DirFor(configFilePath string) string { + if configFilePath == "" { + return "" + } + return filepath.Join(filepath.Dir(configFilePath), DirName) +} + +// ValidateName enforces the script-name token: 1-MaxNameLen characters of +// [A-Za-z0-9_-]. This is the confinement boundary and performs NO filesystem +// access — a valid name has no separators and no dots, so it cannot traverse. +func ValidateName(name string) error { + if name == "" { + return &InvalidNameError{Name: name, Reason: "name is empty"} + } + if len(name) > MaxNameLen { + return &InvalidNameError{Name: name, Reason: fmt.Sprintf("name is %d characters long", len(name))} + } + for i := 0; i < len(name); i++ { + c := name[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_': + default: + return &InvalidNameError{Name: name, Reason: fmt.Sprintf("character %q is not allowed", string(name[i]))} + } + } + return nil +} + +// DeriveLanguage maps a script extension to a code_execution language and +// rejects an explicit language that contradicts it. An empty explicit language +// always agrees. +func DeriveLanguage(name, ext, explicitLanguage string) (string, error) { + var derived string + switch ext { + case extJS: + derived = LanguageJavaScript + case extTS: + derived = LanguageTypeScript + default: + return "", &InvalidError{Name: name, Reason: ReasonNonRegular, Detail: fmt.Sprintf("unsupported extension %q", ext)} + } + if explicitLanguage != "" && explicitLanguage != derived { + return "", &LanguageMismatchError{Name: name, Extension: ext, Requested: explicitLanguage, Derived: derived} + } + return derived, nil +} + +// Resolve reads the stored script `name` from scriptsDir and returns its +// source together with the language derived from its extension. +// +// Order matters: the name is validated BEFORE any filesystem call (SC-003), +// then the directory decides which candidates exist, then the surviving +// candidate is opened with the platform's no-follow idiom and read through a +// bounded reader. Exactly one open and one read per call — no cache, no re-read. +func Resolve(scriptsDir, name, explicitLanguage string) (source []byte, language string, err error) { + if err := ValidateName(name); err != nil { + return nil, "", err + } + + // An empty scripts dir would make filepath.Join produce a bare relative + // path resolved against the process CWD — never that. No authority means + // no scripts. + if scriptsDir == "" { + return nil, "", newNotFoundError(scriptsDir, name) + } + + found, err := candidatesFor(scriptsDir, name) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, "", newNotFoundError(scriptsDir, name) + } + return nil, "", &InvalidError{Name: name, Path: scriptsDir, Reason: ReasonUnreadable, Detail: err.Error()} + } + + switch len(found) { + case 0: + return nil, "", newNotFoundError(scriptsDir, name) + case 1: + default: + return nil, "", &AmbiguousError{Name: name, Paths: found} + } + + path := found[0] + lang, err := DeriveLanguage(name, filepath.Ext(path), explicitLanguage) + if err != nil { + return nil, "", err + } + + f, err := openScriptFile(path) + if err != nil { + switch { + case errors.Is(err, errNonRegular): + return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonNonRegular} + case errors.Is(err, fs.ErrNotExist): + // Removed between the probe and the open. + return nil, "", newNotFoundError(scriptsDir, name) + default: + return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonUnreadable, Detail: err.Error()} + } + } + defer f.Close() + + // Re-verify on the open descriptor: this is the file that will actually be + // read, whatever the path pointed at a moment ago. + info, err := f.Stat() + if err != nil { + return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonUnreadable, Detail: err.Error()} + } + if !info.Mode().IsRegular() { + return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonNonRegular} + } + + // Bound the read itself rather than trusting the stat size: a file that + // grows between stat and read would otherwise execute truncated content. + // One extra byte is requested purely to detect the overflow. + data, err := io.ReadAll(io.LimitReader(f, MaxSizeBytes+1)) + if err != nil { + return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonUnreadable, Detail: err.Error()} + } + if len(data) > MaxSizeBytes { + return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonOversized} + } + if len(data) == 0 { + return nil, "", &InvalidError{Name: name, Path: path, Reason: ReasonEmpty} + } + + return data, lang, nil +} + +// candidatesFor returns the paths of the script files backing `name`, in +// extension order (.js then .ts), by reading the directory and comparing entry +// names BYTE FOR BYTE — the same rule List applies. +// +// The obvious implementation, stat-ing the two constructed paths, delegates the +// name→file decision to the filesystem, and on the default macOS and Windows +// volumes that decision is case-insensitive. `backdoor.JS` then satisfied a +// probe for `backdoor.js` and executed, while every discovery surface — the +// listing, GET /api/v1/code/scripts, the not-found error — skipped it as an +// unknown extension; conversely `foo.js` plus `FOO.ts` were two ok listing +// entries that both refused to run as ambiguous. Reading the directory removes +// the filesystem's matching from the loop entirely, so the two agree on every +// platform. Resolve's no-follow open remains the authoritative check. +func candidatesFor(scriptsDir, name string) ([]string, error) { + dirEntries, err := os.ReadDir(scriptsDir) + if err != nil { + return nil, err + } + + present := make(map[string]bool, 2) + for _, d := range dirEntries { + switch d.Name() { + case name + extJS: + present[extJS] = true + case name + extTS: + present[extTS] = true + } + } + + found := make([]string, 0, 2) + for _, ext := range []string{extJS, extTS} { + if present[ext] { + found = append(found, filepath.Join(scriptsDir, name+ext)) + } + } + return found, nil +} + +// newNotFoundError builds the discovery-carrying not-found error (FR-004). +// A listing failure is not fatal here: the caller still gets "not found". +func newNotFoundError(scriptsDir, name string) *NotFoundError { + err := &NotFoundError{Name: name, Dir: scriptsDir} + if scriptsDir == "" { + return err + } + entries, listErr := List(scriptsDir) + if listErr != nil { + return err + } + for _, e := range entries { + if e.Status != StatusOK { + continue + } + err.Total++ + if len(err.Available) < MaxErrorNames { + err.Available = append(err.Available, e.Name) + } + } + return err +} + +// List enumerates the token-valid stored scripts in scriptsDir, alphabetically +// by name. An absent (or unset) directory is an empty list, not an error +// (FR-007). Statuses are advisory — Resolve re-checks at invocation time. +func List(scriptsDir string) ([]Entry, error) { + if scriptsDir == "" { + return []Entry{}, nil + } + dirEntries, err := os.ReadDir(scriptsDir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return []Entry{}, nil + } + return nil, fmt.Errorf("failed to read scripts directory %s: %w", scriptsDir, err) + } + + // name -> extension -> dir entry, so both candidates of an ambiguous name + // are collected before any status is decided. + candidates := make(map[string]map[string]fs.DirEntry, len(dirEntries)) + for _, d := range dirEntries { + ext := filepath.Ext(d.Name()) + if ext != extJS && ext != extTS { + continue + } + base := strings.TrimSuffix(d.Name(), ext) + if ValidateName(base) != nil { + continue + } + if candidates[base] == nil { + candidates[base] = make(map[string]fs.DirEntry, 2) + } + candidates[base][ext] = d + } + + names := make([]string, 0, len(candidates)) + for name := range candidates { + names = append(names, name) + } + sort.Strings(names) + + entries := make([]Entry, 0, len(names)) + for _, name := range names { + byExt := candidates[name] + if len(byExt) == 2 { + entries = append(entries, Entry{ + Name: name, + Paths: []string{filepath.Join(scriptsDir, name+extJS), filepath.Join(scriptsDir, name+extTS)}, + Status: StatusAmbiguous, + }) + continue + } + ext := extJS + if _, ok := byExt[extTS]; ok { + ext = extTS + } + entries = append(entries, describeEntry(scriptsDir, name, ext, byExt[ext])) + } + return entries, nil +} + +// describeEntry classifies a single candidate file for the listing. +func describeEntry(scriptsDir, name, ext string, d fs.DirEntry) Entry { + entry := Entry{Name: name, Paths: []string{filepath.Join(scriptsDir, name+ext)}, Status: StatusOK} + info, err := d.Info() + switch { + case err != nil: + entry.Status, entry.Reason = StatusInvalid, ReasonUnreadable + case !info.Mode().IsRegular(): + entry.Status, entry.Reason = StatusInvalid, ReasonNonRegular + case info.Size() == 0: + entry.Status, entry.Reason = StatusInvalid, ReasonEmpty + case info.Size() > MaxSizeBytes: + entry.Status, entry.Reason = StatusInvalid, ReasonOversized + } + return entry +} + +// truncateForMessage bounds caller-supplied text echoed back in an error. +func truncateForMessage(s string) string { + const limit = MaxNameLen + 16 + if len(s) <= limit { + return s + } + return s[:limit] + "…" +} diff --git a/internal/codescripts/codescripts_test.go b/internal/codescripts/codescripts_test.go new file mode 100644 index 000000000..fdb8c9b14 --- /dev/null +++ b/internal/codescripts/codescripts_test.go @@ -0,0 +1,617 @@ +package codescripts + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeScript writes a script file into dir and returns its path. +func writeScript(t *testing.T, dir, filename, content string) string { + t.Helper() + require.NoError(t, os.MkdirAll(dir, 0o755)) + path := filepath.Join(dir, filename) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + return path +} + +// traversalCorpus is the SC-003 corpus: every entry must be rejected as an +// invalid NAME, before any filesystem access happens. +var traversalCorpus = []struct { + name string + value string +}{ + {"empty", ""}, + {"dot", "."}, + {"dotdot", ".."}, + {"relative traversal", "../etc/passwd"}, + {"relative traversal windows separators", "..\\..\\windows\\win.ini"}, + {"absolute unix path", "/etc/passwd"}, + {"absolute windows path", `C:\Windows\win.ini`}, + {"forward separator", "sub/script"}, + {"backslash separator", `sub\script`}, + {"leading dot name", ".hidden"}, + {"name with extension", "fetch-prs.js"}, + {"dot segment inside", "a/./b"}, + {"unicode letters", "scrïpt"}, + {"unicode homoglyph separator", "a\u2044b"}, + {"space", "fetch prs"}, + {"nul byte", "fetch\x00prs"}, + {"colon", "stream:name"}, + {"tilde home", "~/script"}, + {"url encoded traversal", "%2e%2e%2fscript"}, + {"too long", strings.Repeat("a", MaxNameLen+1)}, + {"newline", "fetch\nprs"}, + {"glob", "fetch*"}, +} + +// TestValidateName_TraversalCorpus proves the name validator — the confinement +// boundary (FR-003 / SC-003) — rejects every traversal-shaped value and accepts +// only the documented token. +func TestValidateName_TraversalCorpus(t *testing.T) { + for _, tc := range traversalCorpus { + t.Run(tc.name, func(t *testing.T) { + err := ValidateName(tc.value) + require.Error(t, err, "value %q must be rejected", tc.value) + var invalid *InvalidNameError + require.True(t, errors.As(err, &invalid), "want *InvalidNameError, got %T: %v", err, err) + }) + } + + valid := []string{ + "a", + "fetch-prs", + "fetch_prs", + "Fetch2PRs", + "0", + "-leading-hyphen", + "_leading_underscore", + strings.Repeat("a", MaxNameLen), + } + for _, name := range valid { + t.Run("valid/"+name, func(t *testing.T) { + require.NoError(t, ValidateName(name)) + }) + } +} + +// TestResolve_ValidatesNameBeforeFilesystemAccess is the SC-003 ordering proof: +// with a scripts directory that does not exist (so ANY filesystem probe would +// report "not found"), an invalid name still comes back as an invalid-NAME +// error — the validator ran first. A valid name against the same directory +// yields NotFound, showing the filesystem is reached only after validation. +func TestResolve_ValidatesNameBeforeFilesystemAccess(t *testing.T) { + missingDir := filepath.Join(t.TempDir(), "no-such-dir") + + for _, tc := range traversalCorpus { + t.Run(tc.name, func(t *testing.T) { + _, _, err := Resolve(missingDir, tc.value, "") + require.Error(t, err) + var invalid *InvalidNameError + require.True(t, errors.As(err, &invalid), + "invalid name %q must be rejected before any filesystem access; got %T: %v", tc.value, err, err) + }) + } + + _, _, err := Resolve(missingDir, "valid-name", "") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "valid name against a missing dir must reach the filesystem: %v", err) + assert.Equal(t, 0, notFound.Total) + + _, statErr := os.Stat(missingDir) + assert.True(t, os.IsNotExist(statErr), "resolution must never create the scripts directory") +} + +// TestResolve_TraversalCorpusWithExistingTargets closes the hole the +// missing-directory corpus leaves open. There, every traversal value misses the +// filesystem anyway, so an invalid-NAME answer is equally consistent with +// "validated first" and "probed first, then explained the miss nicely". Here the +// escape TARGET EXISTS: a resolver that probed before validating would open it +// and hand back its bytes. Rejection therefore proves the ordering SC-003 +// requires, not just the outcome. +func TestResolve_TraversalCorpusWithExistingTargets(t *testing.T) { + const canary = "({pwned: true})" + + root := t.TempDir() + scriptsDir := filepath.Join(root, "scripts") + require.NoError(t, os.MkdirAll(scriptsDir, 0o755)) + + // Every planted file is a real, readable, correctly-named script — the only + // thing wrong with reaching it is the path used to get there. + writeScript(t, filepath.Join(root, "outside"), "evil.js", canary) + writeScript(t, filepath.Join(scriptsDir, "sub"), "nested.js", canary) + writeScript(t, scriptsDir, "sibling.js", canary) + + cases := []struct { + name string + value string + }{ + {"parent traversal to an existing file", "../outside/evil"}, + {"nested existing file", "sub/nested"}, + {"dot segment onto an existing sibling", "./sibling"}, + {"absolute path to an existing file", filepath.Join(root, "outside", "evil")}, + {"name carrying the extension of an existing file", "sibling.js"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + src, _, err := Resolve(scriptsDir, tc.value, "") + require.Error(t, err, "value %q reaches an existing file and must be rejected", tc.value) + var invalid *InvalidNameError + require.True(t, errors.As(err, &invalid), + "invalid name %q must be rejected by the validator, before any filesystem call; got %T: %v", tc.value, err, err) + assert.NotContains(t, string(src), "pwned", "no bytes may be read from outside the scripts directory") + }) + } +} + +// TestResolve_ValidatesNameBeforeReadingTheDirectory is the ordering half of +// SC-003, and the half a missing directory cannot show: against a scripts +// directory that EXISTS but cannot be read, any implementation that touched the +// filesystem before validating would surface the read failure (an unreadable +// InvalidError), while one that validates first still answers with the name +// error. The two orderings therefore produce different error types here, which +// is exactly what "rejected before any filesystem call" has to mean. +func TestResolve_ValidatesNameBeforeReadingTheDirectory(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root: directory permissions are not enforced") + } + if runtime.GOOS == "windows" { + t.Skip("POSIX permission bits are not enforced on Windows") + } + + scriptsDir := filepath.Join(t.TempDir(), "scripts") + require.NoError(t, os.MkdirAll(scriptsDir, 0o755)) + writeScript(t, scriptsDir, "present.js", "1") + require.NoError(t, os.Chmod(scriptsDir, 0o000)) + t.Cleanup(func() { _ = os.Chmod(scriptsDir, 0o755) }) + + // The directory really is unreadable: a VALID name reports that, so the + // name error below cannot be a coincidence of the directory being empty. + _, _, err := Resolve(scriptsDir, "present", "") + var unreadable *InvalidError + require.True(t, errors.As(err, &unreadable), "want *InvalidError, got %T: %v", err, err) + require.Equal(t, ReasonUnreadable, unreadable.Reason) + + for _, tc := range traversalCorpus { + t.Run(tc.name, func(t *testing.T) { + _, _, err := Resolve(scriptsDir, tc.value, "") + require.Error(t, err) + var invalid *InvalidNameError + require.True(t, errors.As(err, &invalid), + "invalid name %q must be rejected before the directory is read; got %T: %v", tc.value, err, err) + }) + } +} + +// TestResolve_ExtensionCaseIsExact pins that name→file mapping is decided by an +// exact byte comparison against the directory's real entries, not by the +// filesystem's own name matching. On a case-insensitive volume (default macOS +// APFS, NTFS) a constructed-path probe for "backdoor.js" happily opens +// `backdoor.JS` — a file the listing, GET /api/v1/code/scripts and the +// not-found error all omit, because they compare extensions exactly. A name +// that executes but no discovery surface reports is worse than no listing at +// all, so the resolver has to agree with the listing on every platform. +func TestResolve_ExtensionCaseIsExact(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "backdoor.JS", "({pwned: true})") + writeScript(t, dir, "shouty.TS", "({pwned: true})") + + for _, name := range []string{"backdoor", "shouty"} { + t.Run(name, func(t *testing.T) { + src, _, err := Resolve(dir, name, "") + require.Error(t, err, "an uppercase extension is not a stored script") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + assert.NotContains(t, string(src), "pwned") + }) + } + + entries, err := List(dir) + require.NoError(t, err) + assert.Empty(t, entries, "the listing must agree: neither file is a stored script") +} + +// TestResolve_CaseDistinctNamesAreDistinctScripts is the other direction of the +// same seam: `foo.js` and `FOO.ts` are two independent names to the listing, and +// a constructed-path probe on a case-insensitive volume found both extensions +// for either name and called it ambiguous. Each must resolve to its own file. +func TestResolve_CaseDistinctNamesAreDistinctScripts(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "foo.js", "({from: 'js'})") + writeScript(t, dir, "FOO.ts", "({from: 'ts'})") + + src, lang, err := Resolve(dir, "foo", "") + require.NoError(t, err, "foo.js is the only exact-cased match for \"foo\"") + assert.Equal(t, "({from: 'js'})", string(src)) + assert.Equal(t, LanguageJavaScript, lang) + + src, lang, err = Resolve(dir, "FOO", "") + require.NoError(t, err, "FOO.ts is the only exact-cased match for \"FOO\"") + assert.Equal(t, "({from: 'ts'})", string(src)) + assert.Equal(t, LanguageTypeScript, lang) + + entries, err := List(dir) + require.NoError(t, err) + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name) + assert.Equal(t, StatusOK, e.Status, "%s is invocable, so the listing must say so", e.Name) + } + assert.Equal(t, []string{"FOO", "foo"}, names) +} + +func TestResolve_JavaScriptAndTypeScript(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "fetch-prs.js", "({ok: true})") + writeScript(t, dir, "typed.ts", "const x: number = 1; ({x})") + + src, lang, err := Resolve(dir, "fetch-prs", "") + require.NoError(t, err) + assert.Equal(t, "({ok: true})", string(src)) + assert.Equal(t, LanguageJavaScript, lang) + + src, lang, err = Resolve(dir, "typed", "") + require.NoError(t, err) + assert.Equal(t, "const x: number = 1; ({x})", string(src)) + assert.Equal(t, LanguageTypeScript, lang) +} + +func TestResolve_ExplicitLanguage(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "typed.ts", "const x: number = 1") + writeScript(t, dir, "plain.js", "1") + + t.Run("agreeing explicit language is accepted", func(t *testing.T) { + _, lang, err := Resolve(dir, "typed", LanguageTypeScript) + require.NoError(t, err) + assert.Equal(t, LanguageTypeScript, lang) + }) + + t.Run("contradicting explicit language is rejected", func(t *testing.T) { + _, _, err := Resolve(dir, "typed", LanguageJavaScript) + require.Error(t, err) + var mismatch *LanguageMismatchError + require.True(t, errors.As(err, &mismatch), "want *LanguageMismatchError, got %T: %v", err, err) + assert.Equal(t, LanguageTypeScript, mismatch.Derived) + assert.Equal(t, LanguageJavaScript, mismatch.Requested) + }) + + t.Run("contradicting explicit language on a .js script is rejected", func(t *testing.T) { + _, _, err := Resolve(dir, "plain", LanguageTypeScript) + var mismatch *LanguageMismatchError + require.True(t, errors.As(err, &mismatch), "want *LanguageMismatchError, got %T: %v", err, err) + }) + + t.Run("unknown explicit language is rejected", func(t *testing.T) { + _, _, err := Resolve(dir, "plain", "python") + var mismatch *LanguageMismatchError + require.True(t, errors.As(err, &mismatch), "want *LanguageMismatchError, got %T: %v", err, err) + }) +} + +// TestResolve_NotFoundListsAvailable pins FR-004: the not-found error is the +// MCP discovery mechanism — first 20 ok names alphabetically plus the total. +func TestResolve_NotFoundListsAvailable(t *testing.T) { + dir := t.TempDir() + for i := 0; i < 25; i++ { + writeScript(t, dir, fmt.Sprintf("script-%02d.js", i), "1") + } + // Noise that must not be counted as available. + writeScript(t, dir, "broken.js", "") + writeScript(t, dir, "not a token.js", "1") + writeScript(t, dir, "readme.md", "docs") + + _, _, err := Resolve(dir, "missing", "") + require.Error(t, err) + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + + require.Len(t, notFound.Available, MaxErrorNames) + assert.Equal(t, 25, notFound.Total, "only ok scripts count as available") + want := make([]string, 0, MaxErrorNames) + for i := 0; i < MaxErrorNames; i++ { + want = append(want, fmt.Sprintf("script-%02d", i)) + } + assert.Equal(t, want, notFound.Available, "available names are alphabetical") + + msg := err.Error() + assert.Contains(t, msg, "missing") + assert.Contains(t, msg, "script-00") + assert.Contains(t, msg, "25") + assert.NotContains(t, msg, "broken", "invalid scripts are not advertised as available") +} + +func TestResolve_NotFoundEmptyDirectory(t *testing.T) { + dir := t.TempDir() + _, _, err := Resolve(dir, "missing", "") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + assert.Empty(t, notFound.Available) + assert.Equal(t, 0, notFound.Total) + assert.Contains(t, err.Error(), "no stored scripts") +} + +func TestResolve_Ambiguous(t *testing.T) { + dir := t.TempDir() + jsPath := writeScript(t, dir, "dup.js", "1") + tsPath := writeScript(t, dir, "dup.ts", "1") + + _, _, err := Resolve(dir, "dup", "") + var ambiguous *AmbiguousError + require.True(t, errors.As(err, &ambiguous), "want *AmbiguousError, got %T: %v", err, err) + assert.Equal(t, []string{jsPath, tsPath}, ambiguous.Paths) +} + +func TestResolve_EmptyAndOversized(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "empty.js", "") + writeScript(t, dir, "at-limit.js", strings.Repeat("a", MaxSizeBytes)) + writeScript(t, dir, "over-limit.js", strings.Repeat("a", MaxSizeBytes+1)) + + t.Run("empty", func(t *testing.T) { + _, _, err := Resolve(dir, "empty", "") + var invalid *InvalidError + require.True(t, errors.As(err, &invalid), "want *InvalidError, got %T: %v", err, err) + assert.Equal(t, ReasonEmpty, invalid.Reason) + }) + + t.Run("exactly at the limit is accepted", func(t *testing.T) { + src, _, err := Resolve(dir, "at-limit", "") + require.NoError(t, err) + assert.Len(t, src, MaxSizeBytes) + }) + + t.Run("one byte over the limit is rejected", func(t *testing.T) { + _, _, err := Resolve(dir, "over-limit", "") + var invalid *InvalidError + require.True(t, errors.As(err, &invalid), "want *InvalidError, got %T: %v", err, err) + assert.Equal(t, ReasonOversized, invalid.Reason) + }) +} + +func TestResolve_Unreadable(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root: permissions are not enforced") + } + if runtime.GOOS == "windows" { + t.Skip("POSIX permission bits are not enforced on Windows") + } + dir := t.TempDir() + path := writeScript(t, dir, "secret.js", "1") + require.NoError(t, os.Chmod(path, 0o000)) + t.Cleanup(func() { _ = os.Chmod(path, 0o644) }) + + _, _, err := Resolve(dir, "secret", "") + var invalid *InvalidError + require.True(t, errors.As(err, &invalid), "want *InvalidError, got %T: %v", err, err) + assert.Equal(t, ReasonUnreadable, invalid.Reason) +} + +func TestResolve_Directory(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "adir.js"), 0o755)) + + _, _, err := Resolve(dir, "adir", "") + var invalid *InvalidError + require.True(t, errors.As(err, &invalid), "want *InvalidError, got %T: %v", err, err) + assert.Equal(t, ReasonNonRegular, invalid.Reason) +} + +// mustSymlink creates a symlink, skipping the test only when the platform +// refuses for privilege reasons (unprivileged Windows). Never build-tagged +// away: a symlink test that silently vanishes proves nothing. +func mustSymlink(t *testing.T, oldname, newname string) { + t.Helper() + if err := os.Symlink(oldname, newname); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("symlink creation requires elevation on this Windows host: %v", err) + } + require.NoError(t, err) + } +} + +// TestResolve_SymlinkRejected covers FR-003's non-regular rejection for the +// three shapes that matter: a link escaping the scripts dir, a link staying +// inside it, and (on Windows) a directory reparse point. +func TestResolve_SymlinkRejected(t *testing.T) { + t.Run("symlink escaping the scripts directory", func(t *testing.T) { + outside := t.TempDir() + target := writeScript(t, outside, "outside.js", "({escaped: true})") + dir := t.TempDir() + mustSymlink(t, target, filepath.Join(dir, "escape.js")) + + _, _, err := Resolve(dir, "escape", "") + var invalid *InvalidError + require.True(t, errors.As(err, &invalid), "want *InvalidError, got %T: %v", err, err) + assert.Equal(t, ReasonNonRegular, invalid.Reason) + }) + + t.Run("symlink inside the scripts directory", func(t *testing.T) { + dir := t.TempDir() + target := writeScript(t, dir, "real.js", "({real: true})") + mustSymlink(t, target, filepath.Join(dir, "alias.js")) + + _, _, err := Resolve(dir, "alias", "") + var invalid *InvalidError + require.True(t, errors.As(err, &invalid), "an in-directory symlink is still not a regular file; got %T: %v", err, err) + assert.Equal(t, ReasonNonRegular, invalid.Reason) + + // The real file next to it stays resolvable. + src, _, err := Resolve(dir, "real", "") + require.NoError(t, err) + assert.Equal(t, "({real: true})", string(src)) + }) + + t.Run("directory reparse point", func(t *testing.T) { + outside := t.TempDir() + writeScript(t, outside, "inner.js", "1") + dir := t.TempDir() + mustSymlink(t, outside, filepath.Join(dir, "linkdir")) + + // The link is a directory, so no .js candidate exists under a + // token-valid name; resolution must not walk through it. + _, _, err := Resolve(dir, "linkdir", "") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + }) +} + +// TestResolve_Freshness pins FR-009: an atomic replacement is picked up by the +// very next resolution, with nothing to invalidate. +func TestResolve_Freshness(t *testing.T) { + dir := t.TempDir() + writeScript(t, dir, "hot.js", "({v: 1})") + + src, _, err := Resolve(dir, "hot", "") + require.NoError(t, err) + assert.Equal(t, "({v: 1})", string(src)) + + staging := filepath.Join(t.TempDir(), "hot.js.tmp") + require.NoError(t, os.WriteFile(staging, []byte("({v: 2})"), 0o644)) + require.NoError(t, os.Rename(staging, filepath.Join(dir, "hot.js"))) + + src, _, err = Resolve(dir, "hot", "") + require.NoError(t, err) + assert.Equal(t, "({v: 2})", string(src), "an atomic replacement must be visible on the next resolution") + + require.NoError(t, os.Remove(filepath.Join(dir, "hot.js"))) + _, _, err = Resolve(dir, "hot", "") + var notFound *NotFoundError + require.True(t, errors.As(err, ¬Found), "a removed script must stop resolving; got %T: %v", err, err) +} + +func TestList(t *testing.T) { + dir := t.TempDir() + okPath := writeScript(t, dir, "alpha.js", "1") + tsPath := writeScript(t, dir, "beta.ts", "1") + dupJS := writeScript(t, dir, "dup.js", "1") + dupTS := writeScript(t, dir, "dup.ts", "1") + writeScript(t, dir, "empty.js", "") + writeScript(t, dir, "huge.js", strings.Repeat("a", MaxSizeBytes+1)) + writeScript(t, dir, "notes.md", "docs") // unsupported extension + writeScript(t, dir, "UPPER.JS", "1") // extensions are lowercase only + writeScript(t, dir, "not a token.js", "1") // not a token-valid name + require.NoError(t, os.MkdirAll(filepath.Join(dir, "nested"), 0o755)) + + entries, err := List(dir) + require.NoError(t, err) + + byName := map[string]Entry{} + names := make([]string, 0, len(entries)) + for _, e := range entries { + byName[e.Name] = e + names = append(names, e.Name) + } + assert.Equal(t, []string{"alpha", "beta", "dup", "empty", "huge"}, names, + "only token-valid .js/.ts entries are listed, alphabetically") + + assert.Equal(t, Entry{Name: "alpha", Paths: []string{okPath}, Status: StatusOK}, byName["alpha"]) + assert.Equal(t, Entry{Name: "beta", Paths: []string{tsPath}, Status: StatusOK}, byName["beta"]) + assert.Equal(t, Entry{Name: "dup", Paths: []string{dupJS, dupTS}, Status: StatusAmbiguous}, byName["dup"]) + assert.Equal(t, StatusInvalid, byName["empty"].Status) + assert.Equal(t, ReasonEmpty, byName["empty"].Reason) + assert.Equal(t, StatusInvalid, byName["huge"].Status) + assert.Equal(t, ReasonOversized, byName["huge"].Reason) +} + +func TestList_MissingAndEmptyDirectory(t *testing.T) { + t.Run("absent directory yields an empty list, not an error", func(t *testing.T) { + entries, err := List(filepath.Join(t.TempDir(), "no-such-dir")) + require.NoError(t, err) + assert.Empty(t, entries) + }) + + t.Run("empty directory yields an empty list", func(t *testing.T) { + entries, err := List(t.TempDir()) + require.NoError(t, err) + assert.Empty(t, entries) + }) + + t.Run("empty scripts dir path yields an empty list", func(t *testing.T) { + entries, err := List("") + require.NoError(t, err) + assert.Empty(t, entries) + }) +} + +func TestList_SymlinkEntryIsNotOK(t *testing.T) { + dir := t.TempDir() + target := writeScript(t, dir, "real.js", "1") + mustSymlink(t, target, filepath.Join(dir, "alias.js")) + + entries, err := List(dir) + require.NoError(t, err) + for _, e := range entries { + if e.Name == "alias" { + assert.Equal(t, StatusInvalid, e.Status) + assert.Equal(t, ReasonNonRegular, e.Reason) + return + } + } + t.Fatalf("alias entry missing from listing: %+v", entries) +} + +func TestDeriveLanguage(t *testing.T) { + tests := []struct { + ext string + explicit string + want string + wantErr bool + }{ + {".js", "", LanguageJavaScript, false}, + {".ts", "", LanguageTypeScript, false}, + {".js", LanguageJavaScript, LanguageJavaScript, false}, + {".ts", LanguageTypeScript, LanguageTypeScript, false}, + {".js", LanguageTypeScript, "", true}, + {".ts", LanguageJavaScript, "", true}, + {".ts", "ruby", "", true}, + } + for _, tc := range tests { + t.Run(tc.ext+"/"+tc.explicit, func(t *testing.T) { + got, err := DeriveLanguage("some-script", tc.ext, tc.explicit) + if tc.wantErr { + require.Error(t, err) + var mismatch *LanguageMismatchError + assert.True(t, errors.As(err, &mismatch), "want *LanguageMismatchError, got %T", err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestDirFor(t *testing.T) { + assert.Equal(t, + filepath.Join("/home", "u", ".mcpproxy", "scripts"), + DirFor(filepath.Join("/home", "u", ".mcpproxy", "mcp_config.json"))) + assert.Empty(t, DirFor(""), "no config path means no scripts directory") +} + +// An empty scripts directory path must never fall through to process-CWD +// resolution: filepath.Join("", "foo.js") is a relative "foo.js", which would +// execute a file from wherever the daemon happens to run. +func TestResolveEmptyScriptsDirNeverTouchesCWD(t *testing.T) { + tmp := t.TempDir() + if err := os.WriteFile(filepath.Join(tmp, "cwdscript.js"), []byte("({})"), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(tmp) + + _, _, err := Resolve("", "cwdscript", "") + var nf *NotFoundError + if !errors.As(err, &nf) { + t.Fatalf("Resolve with empty scriptsDir: want NotFoundError, got %v", err) + } + if nf.Total != 0 || len(nf.Available) != 0 { + t.Fatalf("empty scriptsDir must report no scripts, got %+v", nf) + } +} diff --git a/internal/codescripts/open_fifo_unix_test.go b/internal/codescripts/open_fifo_unix_test.go new file mode 100644 index 000000000..17a917aa6 --- /dev/null +++ b/internal/codescripts/open_fifo_unix_test.go @@ -0,0 +1,46 @@ +//go:build !windows + +package codescripts + +import ( + "errors" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestResolve_FIFOIsRejectedPromptly pins that a non-regular file which BLOCKS +// on open cannot park the handler goroutine. Opening a FIFO read-only waits for +// a writer forever, and a blocked open(2) is not interruptible by context +// cancellation — so the open must not block in the first place, leaving the +// existing regular-file re-verify to reject it. +func TestResolve_FIFOIsRejectedPromptly(t *testing.T) { + dir := t.TempDir() + fifo := filepath.Join(dir, "blocking.js") + if err := syscall.Mkfifo(fifo, 0o644); err != nil { + t.Skipf("filesystem does not support FIFOs: %v", err) + } + + type outcome struct { + err error + } + done := make(chan outcome, 1) + go func() { + _, _, err := Resolve(dir, "blocking", "") + done <- outcome{err: err} + }() + + select { + case got := <-done: + require.Error(t, got.err, "a FIFO is not a regular file and must be rejected") + var invalid *InvalidError + require.True(t, errors.As(got.err, &invalid), "want *InvalidError, got %T: %v", got.err, got.err) + assert.Equal(t, ReasonNonRegular, invalid.Reason) + case <-time.After(10 * time.Second): + t.Fatal("Resolve blocked on a FIFO instead of rejecting it — the open has no writer and never returns") + } +} diff --git a/internal/codescripts/open_unix.go b/internal/codescripts/open_unix.go new file mode 100644 index 000000000..335510f92 --- /dev/null +++ b/internal/codescripts/open_unix.go @@ -0,0 +1,35 @@ +//go:build !windows + +package codescripts + +import ( + "errors" + "os" + "syscall" +) + +// openScriptFile opens a stored script for reading, rejecting a symlink at the +// final path component ATOMICALLY: O_NOFOLLOW makes the kernel refuse the open +// (ELOOP) instead of resolving the link, so there is no check-then-open window +// in which a regular file can be swapped for a link (FR-003). +// +// O_NONBLOCK is what keeps that atomicity affordable. Screening the type with +// an Lstat BEFORE the open would reintroduce the very race O_NOFOLLOW removes, +// but without it a FIFO in the scripts directory blocks the open until a writer +// appears — forever, uninterruptibly, parking the calling handler goroutine. +// O_NONBLOCK is a no-op for a regular-file open (and for its subsequent reads), +// while a FIFO opens immediately and the caller's fstat then rejects it as +// non-regular, which is the answer it was always meant to get. +func openScriptFile(path string) (*os.File, error) { + f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + // ELOOP is the no-follow rejection; EMLINK is what some BSD kernels + // return for the same condition. Both mean "the final component is a + // symlink", which for us is simply not a regular file. + if errors.Is(err, syscall.ELOOP) || errors.Is(err, syscall.EMLINK) { + return nil, errNonRegular + } + return nil, err + } + return f, nil +} diff --git a/internal/codescripts/open_windows.go b/internal/codescripts/open_windows.go new file mode 100644 index 000000000..deab8b2b1 --- /dev/null +++ b/internal/codescripts/open_windows.go @@ -0,0 +1,22 @@ +//go:build windows + +package codescripts + +import "os" + +// openScriptFile opens a stored script for reading. Windows has no O_NOFOLLOW, +// so the symlink/reparse-point rejection is BEST-EFFORT: the path is Lstat'ed +// first and the descriptor re-verified by the caller after the open. The +// residual window is narrow and creating a symlink on Windows requires +// elevation (or developer mode) in the first place; the confinement boundary +// itself is the name validator, which does not depend on this check. +func openScriptFile(path string) (*os.File, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, errNonRegular + } + return os.Open(path) +} diff --git a/internal/config/code_execution.go b/internal/config/code_execution.go new file mode 100644 index 000000000..a700f223c --- /dev/null +++ b/internal/config/code_execution.go @@ -0,0 +1,19 @@ +package config + +import "errors" + +// CodeExecutionDisabledMessage is the single explanation every surface gives +// when EnableCodeExecution is false. The MCP disabled stub, the handler gate +// that covers REST and tray dispatch, and the HTTP 403 body all render this +// string, so a caller who switches transports never has to wonder whether two +// different refusals mean two different things. +const CodeExecutionDisabledMessage = `Code execution is disabled. Enable it by setting "enable_code_execution": true in your mcpproxy configuration file.` + +// ErrCodeExecutionDisabled is the TYPED identity of that refusal, matched with +// errors.Is; what a caller READS is always CodeExecutionDisabledMessage. The +// MCP contract forces the tool handler to answer with an isError result rather +// than a transport error, which leaves the REST layer nothing but a string to +// classify by. Carrying this sentinel out through the dispatch error lets the +// HTTP surface answer 403 — a refusal retrying cannot fix — instead of the 500 +// a flattened message would produce. +var ErrCodeExecutionDisabled = errors.New("code execution is disabled") diff --git a/internal/httpapi/code_exec.go b/internal/httpapi/code_exec.go index d5b13622f..678019259 100644 --- a/internal/httpapi/code_exec.go +++ b/internal/httpapi/code_exec.go @@ -3,6 +3,7 @@ package httpapi import ( "context" "encoding/json" + "errors" "fmt" "net/http" "reflect" @@ -10,6 +11,8 @@ import ( "go.uber.org/zap" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/codescripts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext" ) @@ -22,7 +25,11 @@ const ( // CodeExecRequest represents the request body for code execution. type CodeExecRequest struct { - Code string `json:"code"` + Code string `json:"code"` + // Script names a server-side stored script to run instead of Code + // (Spec 097). Exactly one of Code or Script may be set; the value is a + // bare name, never a path, and only the code_execution tool resolves it. + Script string `json:"script,omitempty"` Language string `json:"language,omitempty"` // "javascript" (default) or "typescript" Input map[string]interface{} `json:"input"` Options CodeExecOptions `json:"options"` @@ -84,9 +91,13 @@ func (h *CodeExecHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Validate required fields - if req.Code == "" { - h.writeError(w, r, http.StatusBadRequest, "MISSING_CODE", "Code field is required") + // Exactly one source: inline code, or the name of a stored script the tool + // resolves (Spec 097). JSON Schema cannot express the XOR and neither can + // this struct, so the rule is checked here — before dispatch — to answer a + // malformed request as a 400 rather than as a tool error. + if (req.Code == "") == (req.Script == "") { + h.writeError(w, r, http.StatusBadRequest, "INVALID_REQUEST", + "Provide exactly one of 'code' (inline source) or 'script' (the name of a stored script)") return } @@ -151,10 +162,16 @@ func (h *CodeExecHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { execOptions["allowed_servers"] = *req.Options.AllowedServers } args := map[string]interface{}{ - "code": req.Code, "input": req.Input, "options": execOptions, } + // Forward whichever source the caller named — for a stored script that is + // the NAME alone, so the tool stays the only execution-time resolver. + if req.Script != "" { + args["script"] = req.Script + } else { + args["code"] = req.Code + } // Pass language if specified if req.Language != "" { @@ -164,6 +181,17 @@ func (h *CodeExecHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Call the code_execution built-in tool result, err := h.toolCaller.CallTool(ctx, "code_execution", args) if err != nil { + // A refusal the caller could have avoided is not a server fault. Naming + // a script that does not exist is the documented discovery path, and a + // mistyped or ambiguous name is a caller mistake; answered as 500 they + // look retryable to an agent's retry policy and count as server errors + // in monitoring. The tool's own explanation is what travels, since that + // text is how the caller recovers. + if status, code, message, ok := classifyCodeExecError(err); ok { + h.logger.Debugw("Code execution refused", "status", status, "code", code, "error", err) + h.writeError(w, r, status, code, message) + return + } h.logger.Errorw("Code execution failed", "error", err) h.writeError(w, r, http.StatusInternalServerError, "EXECUTION_FAILED", err.Error()) return @@ -183,6 +211,55 @@ func (h *CodeExecHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } +// classifyCodeExecError maps a code_execution dispatch failure onto an HTTP +// status. ok=false means the failure is a genuine server-side fault and keeps +// the 500 EXECUTION_FAILED answer. +// +// The MCP contract makes the tool handler answer these with an isError RESULT, +// never a transport error, so the dispatch layer flattens them to a string on +// the way out — which would leave nothing here but prefix matching. It instead +// preserves the typed identity through errors.As/errors.Is (the same technique +// spec 093 uses for a concurrency shed's 429), so the returned message is the +// error's own wording, without the dispatch wrapper's "tool call failed:". +func classifyCodeExecError(err error) (status int, code, message string, ok bool) { + if errors.Is(err, config.ErrCodeExecutionDisabled) { + return http.StatusForbidden, "FEATURE_DISABLED", config.CodeExecutionDisabledMessage, true + } + + var notFound *codescripts.NotFoundError + if errors.As(err, ¬Found) { + // 404 rather than 400: the request is well formed, the script is not + // there — and the message carries the available names (FR-004). + return http.StatusNotFound, "SCRIPT_NOT_FOUND", notFound.Error(), true + } + + var invalidName *codescripts.InvalidNameError + if errors.As(err, &invalidName) { + return http.StatusBadRequest, "INVALID_SCRIPT_NAME", invalidName.Error(), true + } + + var mismatch *codescripts.LanguageMismatchError + if errors.As(err, &mismatch) { + // Same class as the handler's own pre-dispatch language check above. + return http.StatusBadRequest, "INVALID_LANGUAGE", mismatch.Error(), true + } + + var ambiguous *codescripts.AmbiguousError + if errors.As(err, &ambiguous) { + return http.StatusBadRequest, "SCRIPT_UNUSABLE", ambiguous.Error(), true + } + + var invalid *codescripts.InvalidError + if errors.As(err, &invalid) { + // Empty, oversized, unreadable or non-regular: the named script exists + // but cannot run. Nothing the daemon can do about it, and retrying the + // same name will not help until the file is fixed. + return http.StatusBadRequest, "SCRIPT_UNUSABLE", invalid.Error(), true + } + + return 0, "", "", false +} + func (h *CodeExecHandler) parseResult(result interface{}) CodeExecResponse { // Result from CallTool is []mcp.Content (Content array directly) var textJSON string diff --git a/internal/httpapi/code_exec_status_test.go b/internal/httpapi/code_exec_status_test.go new file mode 100644 index 000000000..786a5b963 --- /dev/null +++ b/internal/httpapi/code_exec_status_test.go @@ -0,0 +1,135 @@ +package httpapi_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/codescripts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi" +) + +// postFailingCodeExec runs one POST /api/v1/code/exec against a tool caller +// that fails with err, and returns the recorded response. +func postFailingCodeExec(t *testing.T, body map[string]interface{}, err error) (*httptest.ResponseRecorder, httpapi.CodeExecResponse) { + t.Helper() + + recorder := postCodeExec(t, &mockController{ + callToolFunc: func(context.Context, string, map[string]interface{}) (interface{}, error) { + return nil, err + }, + }, body) + + var decoded httpapi.CodeExecResponse + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &decoded)) + return recorder, decoded +} + +// TestCodeExec_ScriptResolutionFailuresAreClientErrors pins the status CLASS of +// a stored-script rejection. Naming a script that does not exist is the +// documented discovery path (FR-004) and a mistyped name is a caller mistake, +// but both arrived as 500 EXECUTION_FAILED: agent retry policies treat 500 as +// retryable and re-POST a request that can never succeed, and monitoring counts +// typos as server faults. The handler already answers its own caller-mistake +// checks (XOR, language, option bounds) with 400, so the resolution errors have +// to join them. +func TestCodeExec_ScriptResolutionFailuresAreClientErrors(t *testing.T) { + // Server.CallTool wraps whatever the tool dispatch returns; the typed + // identity has to survive that wrapping for the mapping to be possible. + wrap := func(err error) error { return fmt.Errorf("tool call failed: %w", err) } + + tests := []struct { + name string + err error + wantStatus int + wantCode string + wantInMsg string + }{ + { + name: "not found carries the discovery listing", + err: wrap(&codescripts.NotFoundError{ + Name: "nope", + Dir: "/cfg/scripts", + Available: []string{"daily-report"}, + Total: 1, + }), + wantStatus: http.StatusNotFound, + wantCode: "SCRIPT_NOT_FOUND", + wantInMsg: "daily-report", + }, + { + name: "invalid name", + err: wrap(&codescripts.InvalidNameError{Name: "../etc/passwd", Reason: "character \"/\" is not allowed"}), + wantStatus: http.StatusBadRequest, + wantCode: "INVALID_SCRIPT_NAME", + wantInMsg: "invalid script name", + }, + { + name: "ambiguous", + err: wrap(&codescripts.AmbiguousError{Name: "dup", Paths: []string{"/cfg/scripts/dup.js", "/cfg/scripts/dup.ts"}}), + wantStatus: http.StatusBadRequest, + wantCode: "SCRIPT_UNUSABLE", + wantInMsg: "ambiguous", + }, + { + name: "oversized", + err: wrap(&codescripts.InvalidError{Name: "big", Path: "/cfg/scripts/big.js", Reason: codescripts.ReasonOversized}), + wantStatus: http.StatusBadRequest, + wantCode: "SCRIPT_UNUSABLE", + wantInMsg: "limited to", + }, + { + name: "language contradicts the extension", + err: wrap(&codescripts.LanguageMismatchError{ + Name: "typed", Extension: ".ts", Requested: "javascript", Derived: "typescript", + }), + wantStatus: http.StatusBadRequest, + wantCode: "INVALID_LANGUAGE", + wantInMsg: "typescript", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, decoded := postFailingCodeExec(t, map[string]interface{}{"script": "whatever"}, tc.err) + + assert.Equal(t, tc.wantStatus, w.Code) + require.NotNil(t, decoded.Error) + assert.Equal(t, tc.wantCode, decoded.Error.Code) + assert.Contains(t, decoded.Error.Message, tc.wantInMsg, + "the tool's own explanation must survive the status mapping — it is how a caller recovers") + }) + } + + t.Run("a genuine execution fault is still a 500", func(t *testing.T) { + w, decoded := postFailingCodeExec(t, map[string]interface{}{"code": "1"}, fmt.Errorf("tool call failed: js pool exhausted")) + assert.Equal(t, http.StatusInternalServerError, w.Code) + require.NotNil(t, decoded.Error) + assert.Equal(t, "EXECUTION_FAILED", decoded.Error.Code) + }) +} + +// TestCodeExec_DisabledFeatureIsForbidden pins the REST answer when the +// operator has switched code execution off: a refusal the caller cannot fix by +// retrying, not a server fault. +func TestCodeExec_DisabledFeatureIsForbidden(t *testing.T) { + err := fmt.Errorf("tool call failed: %w", config.ErrCodeExecutionDisabled) + + for _, body := range []map[string]interface{}{ + {"script": "daily-report"}, + {"code": "({result: 1})"}, + } { + w, decoded := postFailingCodeExec(t, body, err) + assert.Equal(t, http.StatusForbidden, w.Code) + require.NotNil(t, decoded.Error) + assert.Equal(t, "FEATURE_DISABLED", decoded.Error.Code) + assert.Contains(t, decoded.Error.Message, "enable_code_execution") + } +} diff --git a/internal/httpapi/code_exec_test.go b/internal/httpapi/code_exec_test.go index ce8abe496..3688d7d54 100644 --- a/internal/httpapi/code_exec_test.go +++ b/internal/httpapi/code_exec_test.go @@ -383,3 +383,90 @@ func TestCodeExecHandler_ExecutionError(t *testing.T) { errorMap := response["error"].(map[string]interface{}) assert.Equal(t, "SYNTAX_ERROR", errorMap["code"]) } + +// postCodeExec runs one request through the handler and returns the recorder. +func postCodeExec(t *testing.T, ctrl httpapi.ToolCaller, body map[string]interface{}) *httptest.ResponseRecorder { + t.Helper() + + bodyBytes, err := json.Marshal(body) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/api/v1/code/exec", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + httpapi.NewCodeExecHandler(ctrl, zap.NewNop().Sugar()).ServeHTTP(recorder, req) + return recorder +} + +// TestCodeExecHandler_ScriptXORCode (Spec 097, T008) pins the REST half of the +// exactly-one-of rule: a request that names both sources, or neither, is a +// malformed request and is answered as one — with this endpoint's own +// {ok:false, error:{code}} envelope — instead of reaching the tool. +func TestCodeExecHandler_ScriptXORCode(t *testing.T) { + tests := []struct { + name string + body map[string]interface{} + }{ + { + name: "both code and script", + body: map[string]interface{}{"code": "({ result: 1 })", "script": "daily-report"}, + }, + { + name: "neither code nor script", + body: map[string]interface{}{"input": map[string]interface{}{}}, + }, + { + name: "empty strings count as absent", + body: map[string]interface{}{"code": "", "script": ""}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dispatched := false + ctrl := &mockController{ + callToolFunc: func(context.Context, string, map[string]interface{}) (interface{}, error) { + dispatched = true + return nil, nil + }, + } + + recorder := postCodeExec(t, ctrl, tc.body) + assert.Equal(t, http.StatusBadRequest, recorder.Code, "body: %s", recorder.Body.String()) + assert.False(t, dispatched, "a malformed request must not reach the code_execution tool") + + var response map[string]interface{} + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + assert.False(t, response["ok"].(bool)) + errorMap, ok := response["error"].(map[string]interface{}) + require.True(t, ok, "response carries no error object: %s", recorder.Body.String()) + assert.Equal(t, "INVALID_REQUEST", errorMap["code"]) + assert.Contains(t, errorMap["message"], "exactly one") + }) + } +} + +// TestCodeExecHandler_ScriptForwardedAsName: over REST too, only the NAME +// travels — the daemon's code_execution handler is the single execution-time +// resolver, so REST cannot smuggle in source of its own. +func TestCodeExecHandler_ScriptForwardedAsName(t *testing.T) { + var dispatched map[string]interface{} + ctrl := &mockController{ + callToolFunc: func(_ context.Context, toolName string, args map[string]interface{}) (interface{}, error) { + assert.Equal(t, "code_execution", toolName) + dispatched = args + resultJSON, err := json.Marshal(map[string]interface{}{"ok": true, "value": 42}) + require.NoError(t, err) + return []interface{}{map[string]interface{}{"type": "text", "text": string(resultJSON)}}, nil + }, + } + + recorder := postCodeExec(t, ctrl, map[string]interface{}{ + "script": "daily-report", + "input": map[string]interface{}{"value": 21}, + }) + require.Equal(t, http.StatusOK, recorder.Code, "body: %s", recorder.Body.String()) + + require.NotNil(t, dispatched, "the request never reached the tool") + assert.Equal(t, "daily-report", dispatched["script"]) + assert.NotContains(t, dispatched, "code", "a stored-script request carries no inline code") +} diff --git a/internal/httpapi/code_scripts.go b/internal/httpapi/code_scripts.go new file mode 100644 index 000000000..8282eeaa2 --- /dev/null +++ b/internal/httpapi/code_scripts.go @@ -0,0 +1,41 @@ +package httpapi + +import ( + "net/http" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/codescripts" +) + +// CodeScriptsResponse is the payload of GET /api/v1/code/scripts: every +// token-valid stored script found next to the active config file, plus the +// directory they were read from so a caller can see WHERE the daemon looked. +type CodeScriptsResponse struct { + Scripts []codescripts.Entry `json:"scripts"` + Dir string `json:"dir"` +} + +// handleListScripts godoc +// @Summary List stored code-execution scripts +// @Description List the stored scripts available to the code_execution tool. Scripts are `.js` / `.ts` files in the `scripts/` directory next to the active configuration file. Entries are advisory: `ok` scripts are invocable, `ambiguous` names have both extensions, and `invalid` ones report why (empty, oversized, unreadable, non-regular). Read-only — there is no write surface for stored scripts. +// @Tags code +// @Produce json +// @Security ApiKeyAuth +// @Security ApiKeyQuery +// @Success 200 {object} contracts.SuccessResponse "Stored scripts and the directory they were read from" +// @Failure 500 {object} contracts.ErrorResponse "Internal server error" +// @Router /api/v1/code/scripts [get] +func (s *Server) handleListScripts(w http.ResponseWriter, r *http.Request) { + // The scripts directory follows the ACTIVE config file, the same authority + // the code_execution handler resolves against — a listing that disagreed + // with what executes would be worse than no listing at all. + dir := codescripts.DirFor(s.controller.GetConfigPath()) + + entries, err := codescripts.List(dir) + if err != nil { + s.getRequestLogger(r).Errorw("Failed to list stored scripts", "dir", dir, "error", err) + s.writeError(w, r, http.StatusInternalServerError, err.Error()) + return + } + + s.writeSuccess(w, CodeScriptsResponse{Scripts: entries, Dir: dir}) +} diff --git a/internal/httpapi/code_scripts_test.go b/internal/httpapi/code_scripts_test.go new file mode 100644 index 000000000..c20c4f54c --- /dev/null +++ b/internal/httpapi/code_scripts_test.go @@ -0,0 +1,141 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/codescripts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// codeScriptsController serves the ACTIVE config file path, which is the sole +// authority for where stored scripts live (Spec 097). +type codeScriptsController struct { + baseController + apiKey string + configPath string +} + +func (c *codeScriptsController) GetCurrentConfig() interface{} { + return &config.Config{APIKey: c.apiKey} +} + +func (c *codeScriptsController) GetConfigPath() string { return c.configPath } + +// newCodeScriptsServer wires a server whose config file lives in a fresh temp +// directory and returns it with the scripts directory that implies. +func newCodeScriptsServer(t *testing.T, apiKey string) (*Server, string) { + t.Helper() + + dir := t.TempDir() + configPath := filepath.Join(dir, "mcp_config.json") + srv := NewServer(&codeScriptsController{apiKey: apiKey, configPath: configPath}, zap.NewNop().Sugar(), nil) + return srv, filepath.Join(dir, codescripts.DirName) +} + +func getCodeScripts(t *testing.T, srv *Server, apiKey string) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/code/scripts", nil) + if apiKey != "" { + req.Header.Set("X-API-Key", apiKey) + } + recorder := httptest.NewRecorder() + srv.ServeHTTP(recorder, req) + return recorder +} + +// TestHandleListScripts_ReportsEveryEntry (T008) pins the discovery contract: +// every token-valid script in the directory is listed with its status, so a +// user can see WHY a file they created is not invocable. +func TestHandleListScripts_ReportsEveryEntry(t *testing.T) { + const apiKey = "test-code-scripts-key" + srv, scriptsDir := newCodeScriptsServer(t, apiKey) + require.NoError(t, os.MkdirAll(scriptsDir, 0o755)) + + require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, "alpha.js"), []byte("({a: 1})"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, "typed.ts"), []byte("const a: number = 1"), 0o600)) + // Both extensions for one name: invocable by neither, reported as such. + require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, "both.js"), []byte("1"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, "both.ts"), []byte("1"), 0o600)) + // Present but unusable. + require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, "blank.js"), nil, 0o600)) + // Not a script at all. + require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, "notes.txt"), []byte("hi"), 0o600)) + + recorder := getCodeScripts(t, srv, apiKey) + require.Equal(t, http.StatusOK, recorder.Code, "body: %s", recorder.Body.String()) + + var resp struct { + Success bool `json:"success"` + Data struct { + Scripts []codescripts.Entry `json:"scripts"` + Dir string `json:"dir"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.True(t, resp.Success) + assert.Equal(t, scriptsDir, resp.Data.Dir, "the listing names the directory it read") + + byName := map[string]codescripts.Entry{} + for _, entry := range resp.Data.Scripts { + byName[entry.Name] = entry + } + require.Contains(t, byName, "alpha") + assert.Equal(t, codescripts.StatusOK, byName["alpha"].Status) + require.Len(t, byName["alpha"].Paths, 1) + assert.True(t, strings.HasSuffix(byName["alpha"].Paths[0], "alpha.js")) + + require.Contains(t, byName, "typed") + assert.Equal(t, codescripts.StatusOK, byName["typed"].Status) + + require.Contains(t, byName, "both") + assert.Equal(t, codescripts.StatusAmbiguous, byName["both"].Status) + assert.Len(t, byName["both"].Paths, 2) + + require.Contains(t, byName, "blank") + assert.Equal(t, codescripts.StatusInvalid, byName["blank"].Status) + assert.Equal(t, codescripts.ReasonEmpty, byName["blank"].Reason) + + assert.NotContains(t, byName, "notes", "only .js/.ts files are stored scripts") +} + +// TestHandleListScripts_EmptyDirectory: a missing or empty scripts directory is +// an empty list, not an error — nothing is misconfigured about having none. +func TestHandleListScripts_EmptyDirectory(t *testing.T) { + const apiKey = "test-code-scripts-key" + srv, scriptsDir := newCodeScriptsServer(t, apiKey) + + recorder := getCodeScripts(t, srv, apiKey) + require.Equal(t, http.StatusOK, recorder.Code, "body: %s", recorder.Body.String()) + + var resp struct { + Success bool `json:"success"` + Data struct { + Scripts []codescripts.Entry `json:"scripts"` + Dir string `json:"dir"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.True(t, resp.Success) + assert.Empty(t, resp.Data.Scripts) + assert.Equal(t, scriptsDir, resp.Data.Dir) +} + +// TestHandleListScripts_RequiresAPIKey: the listing exposes the names and paths +// of everything stored, so it inherits the /api/v1 key requirement. +func TestHandleListScripts_RequiresAPIKey(t *testing.T) { + srv, _ := newCodeScriptsServer(t, "test-code-scripts-key") + + recorder := getCodeScripts(t, srv, "") + assert.Equal(t, http.StatusUnauthorized, recorder.Code, "body: %s", recorder.Body.String()) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 0d4e8ebba..6d14d70c5 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -842,6 +842,10 @@ func (s *Server) setupRoutes() { // Code execution endpoint (for CLI client mode) r.Post("/code/exec", NewCodeExecHandler(s.controller, s.logger).ServeHTTP) + // Stored scripts (Spec 097). Read-only by design: scripts are authored + // in the filesystem, never through the API. + r.Get("/code/scripts", s.handleListScripts) + // Configuration management. Applying/patching config can add, remove, // enable, disable or quarantine upstream servers (mcpServers), so these // mutating routes carry the agent-token gate too — otherwise an agent diff --git a/internal/server/code_exec_dispatch.go b/internal/server/code_exec_dispatch.go new file mode 100644 index 000000000..f2da98483 --- /dev/null +++ b/internal/server/code_exec_dispatch.go @@ -0,0 +1,74 @@ +package server + +import ( + "context" + "sync" +) + +// codeExecCapture is the side channel that carries a code_execution REFUSAL's +// typed identity out of the MCP dispatch layer. +// +// The MCP contract makes handleCodeExecution answer a refusal with (result, +// nil) — an isError result, never a transport error — so by the time +// CallToolDirect sees it, the only thing left is a string. But the REST surface +// has to distinguish "the operator switched this feature off" (403) and "no +// such stored script" (404, carrying the available names) from a genuine +// execution fault (500), and classifying those by re-parsing prose would break +// the moment a message is reworded. The handler therefore drops the typed error +// into this box on the way out, exactly as the concurrency shed does for its +// 429 (see concurrency_shed.go). +type codeExecCapture struct { + mu sync.Mutex + err error +} + +type codeExecCaptureKeyType struct{} + +var codeExecCaptureKey codeExecCaptureKeyType + +// withCodeExecCapture installs a capture box on ctx. Only the REST/CLI dispatch +// entry point (CallToolDirect) installs one; on the MCP transport path +// recordCodeExecRefusal is a no-op, since there the isError result IS the +// answer. +func withCodeExecCapture(ctx context.Context) (context.Context, *codeExecCapture) { + box := &codeExecCapture{} + return context.WithValue(ctx, codeExecCaptureKey, box), box +} + +// recordCodeExecRefusal stores the typed refusal on the context's capture box, +// if any. +func recordCodeExecRefusal(ctx context.Context, err error) { + if ctx == nil || err == nil { + return + } + box, ok := ctx.Value(codeExecCaptureKey).(*codeExecCapture) + if !ok || box == nil { + return + } + box.mu.Lock() + box.err = err + box.mu.Unlock() +} + +// take returns the captured refusal, if the handler refused the call. +func (c *codeExecCapture) take() error { + if c == nil { + return nil + } + c.mu.Lock() + defer c.mu.Unlock() + return c.err +} + +// codeExecDispatchError carries the agent-readable refusal text out of the MCP +// dispatch layer while keeping the typed identity reachable through +// errors.As/errors.Is — which is what lets the REST handler answer 403/404/400 +// instead of the blanket 500 a flattened string produces. +type codeExecDispatchError struct { + err error + message string +} + +func (e *codeExecDispatchError) Error() string { return e.message } + +func (e *codeExecDispatchError) Unwrap() error { return e.err } diff --git a/internal/server/mcp.go b/internal/server/mcp.go index e2c0de6ba..29beff01d 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -177,6 +177,27 @@ type MCPProxyServer struct { // MCP-32: observability manager for tool-call metrics + OTLP tracing. Nil // when observability is disabled; all use sites must nil-guard. observability *observability.Manager + + // configFilePath is the ACTIVE configuration FILE this server belongs to, + // handed in at construction (Spec 097). It is the authority for anything + // derived from the config directory — today the stored-scripts directory. + // Empty in constructions that did not declare one; see + // activeConfigFilePath() for the fallback order. + configFilePath string +} + +// MCPProxyOption customizes an MCPProxyServer at construction time. +type MCPProxyOption func(*MCPProxyServer) + +// WithConfigFilePath declares the ACTIVE configuration FILE path this server +// serves (Spec 097). Every surface that stands up an MCPProxyServer passes it: +// the daemon from the runtime config service, the CLI's in-process server from +// its own --config resolution. It must be the config FILE, never a directory +// derived from --data-dir, which may be overridden after the file is chosen. +func WithConfigFilePath(path string) MCPProxyOption { + return func(p *MCPProxyServer) { + p.configFilePath = path + } } // SetObservability wires the observability manager used to record tool-call @@ -244,6 +265,7 @@ func NewMCPProxyServer( debugSearch bool, config *config.Config, sigCache *toolsig.Cache, + opts ...MCPProxyOption, ) *MCPProxyServer { // The production path passes the Runtime-owned cache (single owner, // Spec 085 FR-008). Standalone constructions (CLI one-shots, tests) may @@ -487,6 +509,14 @@ func NewMCPProxyServer( hooks: hooks, } + // Apply construction options before anything reads them (tool registration + // below already runs against the finished server). + for _, opt := range opts { + if opt != nil { + opt(proxy) + } + } + // Let the hooks (registered before the proxy existed) reach it. proxyRef.Store(proxy) @@ -915,10 +945,15 @@ func (p *MCPProxyServer) registerTools(_ bool) { mcp.WithDestructiveHintAnnotation(true), mcp.WithReadOnlyHintAnnotation(false), mcp.WithOpenWorldHintAnnotation(true), + // Spec 097: `code` is no longer schema-required — a call may supply + // `script` instead. JSON Schema cannot express the exactly-one-of + // rule, so handleCodeExecution enforces it for every surface. mcp.WithString("code", - mcp.Required(), mcp.Description(codeExecutionCodeDescription), ), + mcp.WithString("script", + mcp.Description(codeExecutionScriptDescription), + ), mcp.WithString("language", mcp.Description(codeExecutionLanguageDescription), mcp.Enum("javascript", "typescript"), @@ -5495,6 +5530,12 @@ func (p *MCPProxyServer) CallToolDirect(ctx context.Context, request mcp.CallToo // into a string by the IsError branch at the bottom of this function. ctx, shed := withShedCapture(ctx) + // Spec 097: the same problem for code_execution's refusals — a disabled + // feature is a 403 and a missing stored script a 404, but the handler can + // only answer with an isError result. Capture the typed refusal so the HTTP + // layer classifies it without re-parsing the message. + ctx, codeExecRefusal := withCodeExecCapture(ctx) + // Route to the appropriate handler based on tool name var result *mcp.CallToolResult var err error @@ -5549,6 +5590,12 @@ func (p *MCPProxyServer) CallToolDirect(ctx context.Context, request mcp.CallToo } if len(result.Content) > 0 { if textContent, ok := result.Content[0].(mcp.TextContent); ok { + // A code_execution refusal keeps its typed identity so the HTTP + // layer can answer 403/404/400 (Spec 097). The message stays the + // agent-readable one either way. + if refusal := codeExecRefusal.take(); refusal != nil { + return nil, &codeExecDispatchError{err: refusal, message: textContent.Text} + } return nil, fmt.Errorf("%s", textContent.Text) } } diff --git a/internal/server/mcp_code_execution.go b/internal/server/mcp_code_execution.go index e73bfebcf..03c8522cb 100644 --- a/internal/server/mcp_code_execution.go +++ b/internal/server/mcp_code_execution.go @@ -9,6 +9,8 @@ import ( "time" "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/codescripts" + "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/jsruntime" "github.com/smart-mcp-proxy/mcpproxy-go/internal/profile" @@ -43,6 +45,9 @@ const ( "(no require(), filesystem, or network access)\n\n" + "**TypeScript support**: Set `language: \"typescript\"` to write TypeScript code with type annotations, interfaces, enums, and generics. " + "Types are automatically stripped before execution.\n\n" + + "**Stored scripts**: Instead of `code`, pass `script: \"\"` to run a script stored server-side in the `scripts/` directory next to mcpproxy's config file — " + + "a long workflow then costs a name per run instead of its full source. Provide exactly one of `code` or `script`. Naming a script that does not exist returns the " + + "available names, which is how you discover what is stored.\n\n" + "**Important runtime rules**:\n" + "- `call_tool` and `call_tools` are strictly SYNCHRONOUS. Do not use `await`.\n" + "- Upstream tools usually return an MCP content array. To parse JSON results: `const data = JSON.parse(res.result.content[0].text);`\n" + @@ -58,6 +63,13 @@ const ( codeExecutionLanguageDescription = "Source code language. When set to 'typescript', the code is automatically transpiled to JavaScript before execution. " + "Type annotations are stripped, enums and namespaces are converted to JavaScript equivalents. Default: 'javascript'." + codeExecutionScriptDescription = "Name of a STORED script to execute instead of sending `code` inline (Spec 097). Scripts live as `.js` / `.ts` files in the `scripts/` " + + "directory next to mcpproxy's active config file and are read fresh on every invocation, so an edited script takes effect immediately. " + + "Provide EXACTLY ONE of `code` or `script`. The name is a bare identifier (letters, digits, '-' and '_'; 1-64 chars) — never a path. " + + "The language comes from the file extension (.js → javascript, .ts → typescript); an explicit `language` that contradicts it is an error. " + + "DISCOVERY: calling with a name that does not exist returns an error listing the available script names (first 20 alphabetically, plus the total), " + + "so the current set can always be recovered from a single failed call. Everything else — `input`, options, sandbox limits, results — behaves exactly as for inline code." + codeExecutionInputDescription = "Input data accessible as global `input` variable in code (default: {})" codeExecutionOptionsDescription = "Execution options: timeout_ms (1-600000, default: 120000), max_tool_calls (>= 0, 0=unlimited), " + @@ -71,23 +83,44 @@ func (p *MCPProxyServer) handleCodeExecution(ctx context.Context, request mcp.Ca p.recordBuiltinTool("code_execution") p.logger.Debug("code_execution tool called") + // enable_code_execution is a FEATURE switch, so it is enforced where every + // surface passes rather than at registration. The MCP surfaces gate by + // omitting the tool or serving a disabled stub, but REST /api/v1/code/exec, + // REST /api/v1/tools/call and the tray all reach this handler through + // CallToolDirect — which routed straight here, letting an API-key holder run + // inline code and, since Spec 097, read and execute a server-side stored + // script while the operator believed the feature was off. The check reads + // the LIVE snapshot so a hot-reloaded flag takes effect on the next call; + // no config at all means nothing to disable. + if cfg := p.currentConfig(); cfg != nil && !cfg.EnableCodeExecution { + recordCodeExecRefusal(ctx, config.ErrCodeExecutionDisabled) + return mcp.NewToolResultError(config.CodeExecutionDisabledMessage), nil + } + // Parse arguments. MaxToolCalls starts at the unset sentinel so an explicit // max_tool_calls: 0 — the documented unlimited override — survives default // resolution instead of being floored to the configured limit. options := jsruntime.ExecutionOptions{MaxToolCalls: codeExecMaxToolCallsUnset} - // Extract code (required) - code, err := request.RequireString("code") - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Missing required parameter 'code': %v", err)), nil - } - // Get all arguments args := request.GetArguments() // Extract language (optional, default: "javascript") - if language, ok := args["language"].(string); ok && language != "" { - options.Language = language + explicitLanguage, errMsg := codeExecStringArg(args, "language") + if errMsg != "" { + return mcp.NewToolResultError(errMsg), nil + } + if explicitLanguage != "" { + options.Language = explicitLanguage + } + + // Spec 097: the source is EITHER inline code or a stored script name, never + // both and never neither. This is resolved before anything else runs — the + // handler is the only execution-time resolver on every surface (MCP, REST + // and both CLI modes send the NAME, never the content). + code, scriptName, errMsg := p.resolveCodeExecutionSource(ctx, args, &options) + if errMsg != "" { + return mcp.NewToolResultError(errMsg), nil } // Extract input (optional) - this is an object @@ -129,11 +162,8 @@ func (p *MCPProxyServer) handleCodeExecution(ctx context.Context, request mcp.Ca executionStart := time.Now() parentCallID := mintCorrelationIDAt(executionStart, "code_execution") - // Get config path (handle nil mainServer for CLI mode) - var configPath string - if p.mainServer != nil { - configPath = p.mainServer.GetConfigPath() - } + // Config path for history records (empty when no authority was wired). + configPath := p.activeConfigFilePath() // Create tool caller adapter that wraps the upstream manager toolCaller := &upstreamToolCaller{ @@ -250,6 +280,7 @@ func (p *MCPProxyServer) handleCodeExecution(ctx context.Context, request mcp.Ca p.logger.Info("executing code", zap.String("execution_id", options.ExecutionID), zap.String("language", effectiveLanguage), + zap.String("script", scriptName), // empty for an inline call (Spec 097) zap.Int("code_length", len(code)), zap.Int("timeout_ms", options.TimeoutMs), zap.Int("max_tool_calls", options.MaxToolCalls), @@ -341,15 +372,11 @@ func (p *MCPProxyServer) handleCodeExecution(ctx context.Context, request mcp.Ca // Record the parent code_execution call in history codeExecRecord := &storage.ToolCallRecord{ - ID: parentCallID, - ServerID: "code_execution", // Special server ID for built-in tool - ServerName: "mcpproxy", // Built-in tool - ToolName: "code_execution", - Arguments: map[string]interface{}{ - "code": code, - "input": options.Input, - "language": effectiveLanguage, - }, + ID: parentCallID, + ServerID: "code_execution", // Special server ID for built-in tool + ServerName: "mcpproxy", // Built-in tool + ToolName: "code_execution", + Arguments: codeExecRecordArguments(code, scriptName, effectiveLanguage, options.Input), Response: result, Duration: int64(executionDuration), Timestamp: executionStart, @@ -402,11 +429,7 @@ func (p *MCPProxyServer) handleCodeExecution(ctx context.Context, request mcp.Ca errorMsg = result.Error.Message } } - codeExecArgs := map[string]interface{}{ - "code": code, - "input": options.Input, - "language": effectiveLanguage, - } + codeExecArgs := codeExecRecordArguments(code, scriptName, effectiveLanguage, options.Input) // Spec 035: Determine content trust for code_execution based on tools called. // If any tool called within the JS sandbox has openWorldHint=true (or nil, default true), @@ -438,6 +461,100 @@ func (p *MCPProxyServer) handleCodeExecution(ctx context.Context, request mcp.Ca }, nil } +// codeExecutionSourceXORMessage explains the Spec 097 exactly-one-of rule. +// JSON Schema cannot express XOR, so the schema marks both parameters optional +// and the handler is the one place that enforces the rule — on every surface. +const codeExecutionSourceXORMessage = "Provide exactly one of 'code' (inline source) or 'script' (the name of a script stored in the 'scripts' directory next to mcpproxy's config file) — not both, not neither." + +// codeExecStringArg reads an optional string argument, returning a user-facing +// message when the value is present but is not a string. +func codeExecStringArg(args map[string]interface{}, key string) (value, errMsg string) { + raw, present := args[key] + if !present || raw == nil { + return "", "" + } + str, ok := raw.(string) + if !ok { + return "", fmt.Sprintf("Parameter '%s' must be a string", key) + } + return str, "" +} + +// resolveCodeExecutionSource applies the exactly-one-of rule and returns the +// source to execute together with the stored-script name it came from (empty +// for an inline call). For a stored script the language is derived from the +// file extension and written back into options, so everything downstream — +// transpilation, logging, records — sees what actually ran. +func (p *MCPProxyServer) resolveCodeExecutionSource(ctx context.Context, args map[string]interface{}, options *jsruntime.ExecutionOptions) (code, scriptName, errMsg string) { + code, errMsg = codeExecStringArg(args, "code") + if errMsg != "" { + return "", "", errMsg + } + scriptName, errMsg = codeExecStringArg(args, "script") + if errMsg != "" { + return "", "", errMsg + } + + if (code == "") == (scriptName == "") { + return "", "", codeExecutionSourceXORMessage + } + if scriptName == "" { + return code, "", "" + } + + source, language, err := codescripts.Resolve(p.scriptsDir(), scriptName, options.Language) + if err != nil { + // Keep the typed identity reachable for the REST surface (404 for a + // name that is not there, 400 for one that cannot run) — the text alone + // would force it to classify these by prose. + recordCodeExecRefusal(ctx, err) + return "", "", fmt.Sprintf("Cannot execute stored script: %v", err) + } + options.Language = language + return string(source), scriptName, "" +} + +// activeConfigFilePath returns the configuration FILE this server belongs to: +// the path declared at construction (WithConfigFilePath — every production +// surface passes it), else the running server's own resolution. +func (p *MCPProxyServer) activeConfigFilePath() string { + if p.configFilePath != "" { + return p.configFilePath + } + if p.mainServer != nil { + return p.mainServer.GetConfigPath() + } + return "" +} + +// scriptsDir resolves the stored-scripts directory (Spec 097 FR-001): the +// `scripts` directory beside the active config file. When no authority was +// declared at all, the data dir's default config path is the documented +// last-resort fallback — never a directory derived from --data-dir alone. +func (p *MCPProxyServer) scriptsDir() string { + configFilePath := p.activeConfigFilePath() + if configFilePath == "" && p.config != nil { + configFilePath = config.GetConfigPath(p.config.DataDir) + } + return codescripts.DirFor(configFilePath) +} + +// codeExecRecordArguments builds the argument payload recorded for a +// code_execution call. History and the activity event share it so they cannot +// disagree: both keep the EXECUTED SOURCE under "code" (Spec 024 parity) and, +// for a stored script, additionally name it. +func codeExecRecordArguments(code, scriptName, language string, input map[string]interface{}) map[string]interface{} { + args := map[string]interface{}{ + "code": code, + "input": input, + "language": language, + } + if scriptName != "" { + args["script"] = scriptName + } + return args +} + // applyCodeExecutionOptions parses the `options` object of a code_execution // call into opts, returning a user-facing message when a value is out of range // or the wrong type (empty string means the options were applied). diff --git a/internal/server/mcp_code_scripts_test.go b/internal/server/mcp_code_scripts_test.go new file mode 100644 index 000000000..3028f75f9 --- /dev/null +++ b/internal/server/mcp_code_scripts_test.go @@ -0,0 +1,679 @@ +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/cache" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/codescripts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/index" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/profile" + "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" +) + +// newStoredScriptProxy builds a code-execution-enabled proxy whose config-file +// authority is an explicit path (the Spec 097 construction-time authority), and +// returns it with the scripts directory that authority implies. +func newStoredScriptProxy(t *testing.T, opts ...MCPProxyOption) (*MCPProxyServer, string) { + t.Helper() + + tmpDir := t.TempDir() + logger := zap.NewNop() + + sm, err := storage.NewManager(tmpDir, logger.Sugar()) + require.NoError(t, err) + t.Cleanup(func() { sm.Close() }) + + idx, err := index.NewManager(tmpDir, logger) + require.NoError(t, err) + t.Cleanup(func() { idx.Close() }) + + cfg := config.DefaultConfig() + cfg.DataDir = tmpDir + cfg.EnableCodeExecution = true + cfg.CodeExecutionPoolSize = 1 + + um := upstream.NewManager(logger, cfg, sm.GetBoltDB(), secret.NewResolver(), sm) + + cm, err := cache.NewManager(sm.GetDB(), logger) + require.NoError(t, err) + t.Cleanup(func() { cm.Close() }) + + tr := truncate.NewTruncator(cfg.ToolResponseLimit) + + if len(opts) == 0 { + opts = []MCPProxyOption{WithConfigFilePath(filepath.Join(tmpDir, "mcp_config.json"))} + } + proxy := NewMCPProxyServer(sm, idx, um, cm, func() *truncate.Truncator { return tr }, logger, nil, false, cfg, nil, opts...) + t.Cleanup(func() { proxy.Close() }) + + scriptsDir := filepath.Join(tmpDir, codescripts.DirName) + require.NoError(t, os.MkdirAll(scriptsDir, 0o755)) + return proxy, scriptsDir +} + +func writeStoredScript(t *testing.T, scriptsDir, filename, content string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, filename), []byte(content), 0o644)) +} + +// callCodeExecution runs the code_execution handler and returns the result. +func callCodeExecution(t *testing.T, proxy *MCPProxyServer, args map[string]interface{}) *mcp.CallToolResult { + t.Helper() + request := mcp.CallToolRequest{Params: mcp.CallToolParams{Name: "code_execution", Arguments: args}} + result, err := proxy.handleCodeExecution(context.Background(), request) + require.NoError(t, err) + require.NotNil(t, result) + return result +} + +func resultText(t *testing.T, result *mcp.CallToolResult) string { + t.Helper() + require.NotEmpty(t, result.Content) + text, ok := result.Content[0].(mcp.TextContent) + require.True(t, ok, "expected text content, got %T", result.Content[0]) + return text.Text +} + +// TestScriptsDirAuthority (T002) pins where the scripts directory comes from: +// the config FILE path handed in at construction, with config.GetConfigPath on +// the data dir only as the documented last-resort fallback. +func TestScriptsDirAuthority(t *testing.T) { + t.Run("explicit construction-time path wins", func(t *testing.T) { + explicit := filepath.Join(t.TempDir(), "elsewhere", "mcp_config.json") + proxy, _ := newStoredScriptProxy(t, WithConfigFilePath(explicit)) + assert.Equal(t, codescripts.DirFor(explicit), proxy.scriptsDir()) + }) + + t.Run("falls back to the data-dir config path when nothing was provided", func(t *testing.T) { + proxy, _ := newStoredScriptProxy(t, MCPProxyOption(func(*MCPProxyServer) {})) + want := codescripts.DirFor(config.GetConfigPath(proxy.config.DataDir)) + assert.Equal(t, want, proxy.scriptsDir()) + }) +} + +// TestCodeExecution_ScriptXORCode (T003) pins FR-002: exactly one of code or +// script, both violations explained rather than silently preferred. +func TestCodeExecution_ScriptXORCode(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "double.js", "({result: input.value * 2})") + + t.Run("both rejected", func(t *testing.T) { + result := callCodeExecution(t, proxy, map[string]interface{}{ + "code": "({result: 1})", + "script": "double", + }) + require.True(t, result.IsError, "supplying both code and script must fail") + assert.Contains(t, resultText(t, result), "exactly one") + }) + + t.Run("neither rejected", func(t *testing.T) { + result := callCodeExecution(t, proxy, map[string]interface{}{ + "input": map[string]interface{}{}, + }) + require.True(t, result.IsError, "supplying neither code nor script must fail") + assert.Contains(t, resultText(t, result), "exactly one") + }) + + t.Run("empty strings count as absent", func(t *testing.T) { + result := callCodeExecution(t, proxy, map[string]interface{}{"code": "", "script": ""}) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "exactly one") + }) + + t.Run("non-string script is rejected", func(t *testing.T) { + result := callCodeExecution(t, proxy, map[string]interface{}{"script": 42}) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "script") + }) +} + +// TestCodeExecution_StoredScriptMatchesInline (T003 / SC-002) executes the same +// source both ways and compares the results byte for byte. +func TestCodeExecution_StoredScriptMatchesInline(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + const source = "({result: input.value * 2, kind: 'stored'})" + writeStoredScript(t, scriptsDir, "double.js", source) + + input := map[string]interface{}{"value": 21} + stored := resultText(t, callCodeExecution(t, proxy, map[string]interface{}{ + "script": "double", + "input": input, + })) + inline := resultText(t, callCodeExecution(t, proxy, map[string]interface{}{ + "code": source, + "input": input, + })) + + assert.Equal(t, inline, stored, "a stored script must execute identically to the same source inline") + assert.Contains(t, stored, `"result":42`) +} + +// TestCodeExecution_StoredScriptMatchesInlineUnderEnforcement is the parity +// case with teeth (SC-002 / FR-005). Comparing a self-contained arithmetic +// script proves the source arrives intact and nothing more; what the shared +// path actually risks is ORDERING — the resolver writes options.Language part +// way through a fixed language→resolve→options→scope sequence, so a stored +// script could silently execute under different option and scope enforcement +// than the same source inline. This runs both branches through call_tool() +// under a caller-set timeout and tool-call budget, an allowed_servers +// restriction and a deny-all profile scope, and requires the answers — and the +// records they leave behind — to agree. +func TestCodeExecution_StoredScriptMatchesInlineUnderEnforcement(t *testing.T) { + // Two calls: one to a server the options exclude, one to a server they + // allow. Neither needs a real upstream — the allow-list is consulted before + // any connection — and the second call also proves the budget was not spent + // refusing the first. + const source = `var denied = call_tool('deploy-srv', 'ship', {}); +var allowed = call_tool('research-srv', 'search', {}); +({ + denied: denied.ok ? 'RAN' : denied.error.code, + allowed: allowed.ok ? 'RAN' : allowed.error.code +})` + + options := map[string]interface{}{ + "timeout_ms": 7500, + "max_tool_calls": 3, + "allowed_servers": []interface{}{"research-srv"}, + } + + // Each branch gets its own proxy so the record it leaves behind is the only + // one in storage, and so neither can observe the other's execution. + run := func(t *testing.T, ctx context.Context, args map[string]interface{}) (text string, rec *storage.ToolCallRecord) { + t.Helper() + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "enforced.js", source) + + request := mcp.CallToolRequest{Params: mcp.CallToolParams{Name: "code_execution", Arguments: args}} + result, err := proxy.handleCodeExecution(ctx, request) + require.NoError(t, err) + require.NotNil(t, result) + + records, err := proxy.storage.GetServerToolCalls("code_execution", 10) + require.NoError(t, err) + require.NotEmpty(t, records, "the parent code_execution call must be recorded") + return resultText(t, result), records[0] + } + + scenarios := []struct { + name string + ctx func() context.Context + }{ + { + name: "allowed_servers restriction", + ctx: context.Background, + }, + { + name: "deny-all profile scope on top", + ctx: func() context.Context { + return profile.WithProfileScope(context.Background(), profile.NewProfileScope("locked", nil)) + }, + }, + } + + for _, sc := range scenarios { + t.Run(sc.name, func(t *testing.T) { + storedText, storedRec := run(t, sc.ctx(), map[string]interface{}{ + "script": "enforced", + "input": map[string]interface{}{}, + "options": options, + }) + inlineText, inlineRec := run(t, sc.ctx(), map[string]interface{}{ + "code": source, + "input": map[string]interface{}{}, + "options": options, + }) + + assert.Equal(t, inlineText, storedText, + "a stored script must be enforced exactly like the same source inline") + assert.Contains(t, storedText, "SERVER_NOT_ALLOWED", + "the excluded server must be refused inside the stored script too") + + // The records agree on everything except the one field a stored + // script is meant to add. + assert.Equal(t, source, storedRec.Arguments["code"], "the record keeps the executed source") + assert.Equal(t, "enforced", storedRec.Arguments["script"]) + assert.NotContains(t, inlineRec.Arguments, "script") + for _, key := range []string{"code", "input", "language"} { + assert.Equal(t, inlineRec.Arguments[key], storedRec.Arguments[key], + "records must agree on %q", key) + } + assert.Equal(t, inlineRec.Error, storedRec.Error) + assert.Equal(t, inlineRec.ExecutionType, storedRec.ExecutionType) + }) + } +} + +// TestCodeExecution_StoredTypeScript pins that the extension derives the +// language, so a .ts stored script transpiles exactly like inline TypeScript. +func TestCodeExecution_StoredTypeScript(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "typed.ts", "const factor: number = 3; ({result: (input.value as number) * factor})") + + text := resultText(t, callCodeExecution(t, proxy, map[string]interface{}{ + "script": "typed", + "input": map[string]interface{}{"value": 4}, + })) + assert.Contains(t, text, `"result":12`) +} + +// TestCodeExecution_ScriptLanguageContradiction: the extension is +// authoritative, an explicit contradicting language is an error rather than a +// silently-ignored parameter. +func TestCodeExecution_ScriptLanguageContradiction(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "typed.ts", "const x: number = 1; ({x})") + + result := callCodeExecution(t, proxy, map[string]interface{}{ + "script": "typed", + "language": "javascript", + }) + require.True(t, result.IsError) + text := resultText(t, result) + assert.Contains(t, text, "typescript") + + // The agreeing language is accepted. + ok := callCodeExecution(t, proxy, map[string]interface{}{ + "script": "typed", + "language": "typescript", + }) + assert.False(t, ok.IsError, "an agreeing language must not be rejected: %s", resultText(t, ok)) +} + +// TestCodeExecution_ScriptNotFoundListsAvailable pins FR-004: the not-found +// error IS the MCP discovery mechanism. +func TestCodeExecution_ScriptNotFoundListsAvailable(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "alpha.js", "1") + writeStoredScript(t, scriptsDir, "beta.ts", "1") + + result := callCodeExecution(t, proxy, map[string]interface{}{"script": "gamma"}) + require.True(t, result.IsError) + text := resultText(t, result) + assert.Contains(t, text, "gamma") + assert.Contains(t, text, "alpha") + assert.Contains(t, text, "beta") + + t.Run("an invalid name never reaches the filesystem", func(t *testing.T) { + result := callCodeExecution(t, proxy, map[string]interface{}{"script": "../../etc/passwd"}) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "invalid script name") + }) +} + +// TestCodeExecution_RecordsCarryScriptAndSource pins FR-005 / research R6: +// history keeps the executed SOURCE as code (Spec 024 parity) and additionally +// names the script. +func TestCodeExecution_RecordsCarryScriptAndSource(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + const source = "({result: 7})" + writeStoredScript(t, scriptsDir, "seven.js", source) + + result := callCodeExecution(t, proxy, map[string]interface{}{"script": "seven"}) + require.False(t, result.IsError, resultText(t, result)) + + records, err := proxy.storage.GetServerToolCalls("code_execution", 10) + require.NoError(t, err) + require.NotEmpty(t, records, "the parent code_execution call must be recorded") + + rec := records[0] + assert.Equal(t, source, rec.Arguments["code"], "records keep the resolved source as code (Spec 024 parity)") + assert.Equal(t, "seven", rec.Arguments["script"], "records additionally name the stored script") + assert.Equal(t, "javascript", rec.Arguments["language"]) + + t.Run("inline calls carry no script key", func(t *testing.T) { + result := callCodeExecution(t, proxy, map[string]interface{}{"code": "({result: 8})"}) + require.False(t, result.IsError, resultText(t, result)) + records, err := proxy.storage.GetServerToolCalls("code_execution", 10) + require.NoError(t, err) + require.NotEmpty(t, records) + assert.NotContains(t, records[0].Arguments, "script") + }) +} + +// TestCodeExecRecordArguments pins that history arguments and the activity +// payload are built by ONE helper, so the two can never disagree about what a +// stored-script execution ran. +func TestCodeExecRecordArguments(t *testing.T) { + input := map[string]interface{}{"a": 1} + + stored := codeExecRecordArguments("src", "name", "javascript", input) + assert.Equal(t, map[string]interface{}{ + "code": "src", + "input": input, + "language": "javascript", + "script": "name", + }, stored) + + inline := codeExecRecordArguments("src", "", "typescript", input) + assert.Equal(t, map[string]interface{}{ + "code": "src", + "input": input, + "language": "typescript", + }, inline) +} + +// TestCodeExecution_StoredScriptFreshness (T003 support / FR-009): an atomic +// replacement is executed by the very next invocation, no restart. +func TestCodeExecution_StoredScriptFreshness(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "hot.js", "({result: 1})") + + first := resultText(t, callCodeExecution(t, proxy, map[string]interface{}{"script": "hot"})) + assert.Contains(t, first, `"result":1`) + + staging := filepath.Join(t.TempDir(), "hot.js") + require.NoError(t, os.WriteFile(staging, []byte("({result: 2})"), 0o644)) + require.NoError(t, os.Rename(staging, filepath.Join(scriptsDir, "hot.js"))) + + second := resultText(t, callCodeExecution(t, proxy, map[string]interface{}{"script": "hot"})) + assert.Contains(t, second, `"result":2`) +} + +// --- T005: the three registration sites --- + +// codeExecutionSchemas returns the code_execution tool schema from every +// surface that registers it. +func codeExecutionSchemas(t *testing.T, proxy *MCPProxyServer) map[string]map[string]interface{} { + t.Helper() + schemas := map[string]map[string]interface{}{} + + if st, ok := proxy.server.ListTools()["code_execution"]; ok { + schemas["default_server"] = toolAsMap(t, st.Tool) + } + for _, st := range proxy.buildCodeExecModeTools() { + if st.Tool.Name == "code_execution" { + schemas["code_execution_mode"] = toolAsMap(t, st.Tool) + } + } + for _, st := range proxy.buildCallToolModeTools() { + if st.Tool.Name == "code_execution" { + schemas["call_tool_mode"] = toolAsMap(t, st.Tool) + } + } + return schemas +} + +func toolAsMap(t *testing.T, tool mcp.Tool) map[string]interface{} { + t.Helper() + raw, err := json.Marshal(tool) + require.NoError(t, err) + var m map[string]interface{} + require.NoError(t, json.Unmarshal(raw, &m)) + return m +} + +func requiredParams(tool map[string]interface{}) []string { + schema, _ := tool["inputSchema"].(map[string]interface{}) + raw, _ := schema["required"].([]interface{}) + out := make([]string, 0, len(raw)) + for _, v := range raw { + if s, ok := v.(string); ok { + out = append(out, s) + } + } + return out +} + +// TestCodeExecutionRegistrations_ScriptParam (T005) asserts every LIVE +// registration advertises the optional script parameter from the one shared +// description, and that code is no longer schema-required (the XOR rule cannot +// be expressed in JSON Schema, so the handler enforces it). +func TestCodeExecutionRegistrations_ScriptParam(t *testing.T) { + proxy, _ := newStoredScriptProxy(t) + schemas := codeExecutionSchemas(t, proxy) + require.Len(t, schemas, 3, "code_execution must be registered on all three surfaces: %v", schemas) + + for surface, tool := range schemas { + props := schemaProps(tool) + require.NotNil(t, props, "surface %s: code_execution lost its inputSchema", surface) + + script, ok := props["script"].(map[string]interface{}) + require.True(t, ok, "surface %s: code_execution must expose the script parameter", surface) + assert.Equal(t, codeExecutionScriptDescription, script["description"], + "surface %s: script description must come from the shared constant", surface) + + assert.NotContains(t, requiredParams(tool), "code", + "surface %s: code must not be schema-required — the handler enforces the XOR", surface) + + desc, _ := tool["description"].(string) + assert.Contains(t, desc, "script", + "surface %s: the tool description must document stored scripts (FR-008)", surface) + } +} + +// TestCodeExecutionDisabledStub_AcceptsScript (T005): a script call must reach +// the disabled handler and get the disabled explanation — so the stub takes the +// parameter, but keeps ONLY its disabled description (no discovery prose). +func TestCodeExecutionDisabledStub_AcceptsScript(t *testing.T) { + proxy := createTestMCPProxyServer(t) + proxy.config.EnableCodeExecution = false + + tools := proxy.buildCodeExecutionTool() + require.Len(t, tools, 1) + stub := toolAsMap(t, tools[0].Tool) + + desc, _ := stub["description"].(string) + assert.Contains(t, desc, "disabled") + assert.NotContains(t, desc, codeExecutionScriptDescription, + "the disabled stub keeps only its disabled description") + assert.False(t, strings.Contains(desc, "call_tools"), + "the disabled stub must not advertise the executable contract") + + props := schemaProps(stub) + require.NotNil(t, props) + _, hasScript := props["script"] + assert.True(t, hasScript, "the stub must accept script so those calls reach the disabled handler") + assert.NotContains(t, requiredParams(stub), "code", + "the stub must not require code, or a script-only call is rejected by schema instead of explained") + + result, err := tools[0].Handler(context.Background(), mcp.CallToolRequest{ + Params: mcp.CallToolParams{Name: "code_execution", Arguments: map[string]interface{}{"script": "anything"}}, + }) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, resultText(t, result), "disabled") +} + +// TestCodeExecution_DisabledGateCoversEveryDispatch pins enable_code_execution +// as a FEATURE switch rather than a tool-registration detail. The MCP surfaces +// gated it by omitting the tool (or serving a disabled stub), but every +// non-MCP caller — REST /api/v1/code/exec, REST /api/v1/tools/call, the tray — +// reaches the handler through CallToolDirect, which routes code_execution +// straight through. With the flag off an API-key holder could still run inline +// code and, since Spec 097, read and execute a server-side stored script. The +// gate belongs on the handler, where every surface passes. +func TestCodeExecution_DisabledGateCoversEveryDispatch(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "sentinel.js", "({result: 'executed'})") + proxy.config.EnableCodeExecution = false + + call := func(t *testing.T, args map[string]interface{}) error { + t.Helper() + _, err := proxy.CallToolDirect(context.Background(), mcp.CallToolRequest{ + Params: mcp.CallToolParams{Name: "code_execution", Arguments: args}, + }) + require.Error(t, err, "a disabled feature must not answer with a result") + return err + } + + t.Run("a stored script is neither resolved nor executed", func(t *testing.T) { + err := call(t, map[string]interface{}{"script": "sentinel"}) + assert.Contains(t, err.Error(), "disabled") + assert.NotContains(t, err.Error(), "executed", "the script must never run") + }) + + t.Run("a missing script name is not answered with the discovery listing", func(t *testing.T) { + err := call(t, map[string]interface{}{"script": "nope"}) + assert.Contains(t, err.Error(), "disabled") + assert.NotContains(t, err.Error(), "sentinel", + "a disabled feature must not enumerate the scripts directory") + }) + + t.Run("inline code is refused too", func(t *testing.T) { + err := call(t, map[string]interface{}{"code": "({result: 'executed'})"}) + assert.Contains(t, err.Error(), "disabled") + assert.NotContains(t, err.Error(), "executed") + }) + + t.Run("the wording matches the disabled stub", func(t *testing.T) { + stub := proxy.buildCodeExecutionTool() + require.Len(t, stub, 1) + result, err := stub[0].Handler(context.Background(), mcp.CallToolRequest{ + Params: mcp.CallToolParams{Name: "code_execution", Arguments: map[string]interface{}{"script": "sentinel"}}, + }) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Equal(t, resultText(t, result), call(t, map[string]interface{}{"script": "sentinel"}).Error(), + "every surface must explain a disabled feature the same way") + }) + + t.Run("re-enabling takes effect without reconstruction", func(t *testing.T) { + proxy.config.EnableCodeExecution = true + t.Cleanup(func() { proxy.config.EnableCodeExecution = false }) + + result := callCodeExecution(t, proxy, map[string]interface{}{"script": "sentinel"}) + require.False(t, result.IsError, resultText(t, result)) + assert.Contains(t, resultText(t, result), "executed") + }) +} + +// TestCodeExecution_RefusalsKeepTheirTypeThroughDispatch is the seam that lets +// the REST surface answer a stored-script rejection with a 4xx. The MCP +// contract makes the handler return an isError RESULT, and CallToolDirect +// flattens that to a plain error — so without a typed channel the HTTP layer +// has nothing but prose to classify by, and every caller mistake arrives as a +// retryable 500. Each refusal must therefore stay reachable through +// errors.As/errors.Is while keeping its agent-readable message. +func TestCodeExecution_RefusalsKeepTheirTypeThroughDispatch(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "alpha.js", "({result: 1})") + writeStoredScript(t, scriptsDir, "dup.js", "1") + writeStoredScript(t, scriptsDir, "dup.ts", "1") + writeStoredScript(t, scriptsDir, "blank.js", "") + writeStoredScript(t, scriptsDir, "typed.ts", "const x: number = 1; ({x})") + + dispatch := func(t *testing.T, args map[string]interface{}) error { + t.Helper() + _, err := proxy.CallToolDirect(context.Background(), mcp.CallToolRequest{ + Params: mcp.CallToolParams{Name: "code_execution", Arguments: args}, + }) + require.Error(t, err) + return err + } + + t.Run("not found", func(t *testing.T) { + err := dispatch(t, map[string]interface{}{"script": "nope"}) + var notFound *codescripts.NotFoundError + require.True(t, errors.As(err, ¬Found), "want *NotFoundError, got %T: %v", err, err) + assert.Contains(t, err.Error(), "alpha", "the discovery listing must survive alongside the type") + }) + + t.Run("invalid name", func(t *testing.T) { + err := dispatch(t, map[string]interface{}{"script": "../../etc/passwd"}) + var invalidName *codescripts.InvalidNameError + assert.True(t, errors.As(err, &invalidName), "want *InvalidNameError, got %T: %v", err, err) + }) + + t.Run("ambiguous", func(t *testing.T) { + err := dispatch(t, map[string]interface{}{"script": "dup"}) + var ambiguous *codescripts.AmbiguousError + assert.True(t, errors.As(err, &ambiguous), "want *AmbiguousError, got %T: %v", err, err) + }) + + t.Run("present but unusable", func(t *testing.T) { + err := dispatch(t, map[string]interface{}{"script": "blank"}) + var invalid *codescripts.InvalidError + require.True(t, errors.As(err, &invalid), "want *InvalidError, got %T: %v", err, err) + assert.Equal(t, codescripts.ReasonEmpty, invalid.Reason) + }) + + t.Run("language contradicts the extension", func(t *testing.T) { + err := dispatch(t, map[string]interface{}{"script": "typed", "language": "javascript"}) + var mismatch *codescripts.LanguageMismatchError + assert.True(t, errors.As(err, &mismatch), "want *LanguageMismatchError, got %T: %v", err, err) + }) + + t.Run("feature disabled", func(t *testing.T) { + proxy.config.EnableCodeExecution = false + t.Cleanup(func() { proxy.config.EnableCodeExecution = true }) + + err := dispatch(t, map[string]interface{}{"script": "alpha"}) + assert.True(t, errors.Is(err, config.ErrCodeExecutionDisabled), "want the disabled sentinel, got %T: %v", err, err) + assert.Equal(t, config.CodeExecutionDisabledMessage, err.Error()) + }) + + t.Run("an execution fault keeps no refusal type", func(t *testing.T) { + // A script that runs and throws is not a refusal: it comes back as a + // normal result envelope, so the REST surface still answers 200 with + // ok:false rather than reclassifying it as a caller mistake. + writeStoredScript(t, scriptsDir, "boom.js", "throw new Error('kaboom')") + result, err := proxy.CallToolDirect(context.Background(), mcp.CallToolRequest{ + Params: mcp.CallToolParams{Name: "code_execution", Arguments: map[string]interface{}{"script": "boom"}}, + }) + require.NoError(t, err, "a thrown error is reported inside the result, not as a dispatch failure") + require.NotNil(t, result) + }) +} + +// TestCodeExecution_EndToEndFreshness (T011 / FR-009) pins the whole +// invocation path against a directory that changes underneath it: an atomic +// replacement is executed by the very next call, and a script added or removed +// after startup is reflected in the very next listing. Nothing caches a script, +// so nothing needs invalidating — and no restart is ever required. +func TestCodeExecution_EndToEndFreshness(t *testing.T) { + proxy, scriptsDir := newStoredScriptProxy(t) + writeStoredScript(t, scriptsDir, "report.js", "({result: 'v1'})") + + // Version 1 executes. + first := resultText(t, callCodeExecution(t, proxy, map[string]interface{}{"script": "report"})) + assert.Contains(t, first, `"result":"v1"`) + + // Replace atomically (write elsewhere, rename over) — the editor-safe way. + staging := filepath.Join(t.TempDir(), "report.js") + require.NoError(t, os.WriteFile(staging, []byte("({result: 'v2'})"), 0o644)) + require.NoError(t, os.Rename(staging, filepath.Join(scriptsDir, "report.js"))) + + second := resultText(t, callCodeExecution(t, proxy, map[string]interface{}{"script": "report"})) + assert.Contains(t, second, `"result":"v2"`, "an atomically replaced script must run on the very next invocation") + + // A script added after the server started is invocable immediately and + // shows up in the listing the discovery surfaces read. + writeStoredScript(t, scriptsDir, "fresh.js", "({result: 'new'})") + added := resultText(t, callCodeExecution(t, proxy, map[string]interface{}{"script": "fresh"})) + assert.Contains(t, added, `"result":"new"`) + + entries, err := codescripts.List(proxy.scriptsDir()) + require.NoError(t, err) + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name) + } + assert.Equal(t, []string{"fresh", "report"}, names, "a newly added script must appear in the next listing") + + // Removing it takes effect just as immediately: the next invocation fails + // with the discovery error, and the listing no longer names it. + require.NoError(t, os.Remove(filepath.Join(scriptsDir, "fresh.js"))) + gone := callCodeExecution(t, proxy, map[string]interface{}{"script": "fresh"}) + require.True(t, gone.IsError, "a removed script must stop being invocable at once") + assert.Contains(t, resultText(t, gone), "not found") + + entries, err = codescripts.List(proxy.scriptsDir()) + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, "report", entries[0].Name) +} diff --git a/internal/server/mcp_menu_surface_test.go b/internal/server/mcp_menu_surface_test.go index 959dca2a4..568efe4cf 100644 --- a/internal/server/mcp_menu_surface_test.go +++ b/internal/server/mcp_menu_surface_test.go @@ -154,6 +154,8 @@ func TestMenuSurface_ExactDeltaFromPreFeature(t *testing.T) { assertRetrieveToolsDelta(t, surface, preM, curM) case callToolVariants[name]: assertCallToolVariantDelta(t, surface, name, preM, curM) + case name == "code_execution": + assertCodeExecutionDelta(t, surface, preM, curM) default: assert.Equal(t, preM, curM, "surface %s: tool %q must be byte-identical to the pre-feature snapshot (SC-003)", surface, name) @@ -300,6 +302,55 @@ func TestMenuSurface_AnnotationFilterParamsShared(t *testing.T) { } } +// Spec 097 widens the controlled delta on code_execution by exactly two +// things: the added optional `script` parameter, and `code` losing its +// schema-required status — JSON Schema cannot express "exactly one of", so the +// handler owns that rule and the schema must accept a script-only call. +// Everything else (annotations, the pre-feature parameter schemas, and — for +// the disabled stub, which is what this surface registers — the description) +// stays byte-identical. +func assertCodeExecutionDelta(t *testing.T, surface string, preM, curM map[string]interface{}) { + t.Helper() + + preProps, curProps := schemaProps(preM), schemaProps(curM) + require.NotNil(t, curProps, "surface %s: code_execution lost its inputSchema", surface) + + var added []string + for p := range curProps { + if _, ok := preProps[p]; !ok { + added = append(added, p) + } + } + sort.Strings(added) + if added == nil { + added = []string{} + } + assert.Equal(t, []string{"script"}, added, + "surface %s: exact code_execution parameter delta (spec 097 FR-002)", surface) + + for p, preSchema := range preProps { + assert.Equal(t, preSchema, curProps[p], + "surface %s: pre-feature code_execution parameter %q must be preserved unchanged", surface, p) + } + + curSchema, _ := curM["inputSchema"].(map[string]interface{}) + assert.Empty(t, curSchema["required"], + "surface %s: code must no longer be schema-required, or a script-only call is rejected before the handler can explain the rule", surface) + + assert.Equal(t, preM["annotations"], curM["annotations"], + "surface %s: code_execution annotations unchanged", surface) + + preDesc, _ := preM["description"].(string) + curDesc, _ := curM["description"].(string) + if strings.Contains(preDesc, "disabled") { + assert.Equal(t, preDesc, curDesc, + "surface %s: the disabled stub keeps ONLY its disabled description — no stored-script prose on a tool that cannot run", surface) + return + } + assert.Contains(t, curDesc, "script", + "surface %s: the live description must document stored scripts (spec 097 FR-008)", surface) +} + // assertCallToolVariantDelta: only the tool description and the 'args' // parameter description may change (FR-014); the new text references // signatures + describe_tool and no longer instructs reading inputSchema from diff --git a/internal/server/mcp_routing.go b/internal/server/mcp_routing.go index b95d14139..0e2710a26 100644 --- a/internal/server/mcp_routing.go +++ b/internal/server/mcp_routing.go @@ -490,15 +490,25 @@ func (p *MCPProxyServer) buildCodeExecutionTool() []mcpserver.ServerTool { mcp.WithReadOnlyHintAnnotation(true), mcp.WithDestructiveHintAnnotation(false), mcp.WithOpenWorldHintAnnotation(false), + // Spec 097: the stub mirrors the live parameter shape — optional + // `code`, optional `script` — so a stored-script call reaches this + // handler and gets the "enable it" explanation instead of a schema + // rejection. Its DESCRIPTIONS stay minimal and disabled-only: a + // disabled tool must not advertise a contract it cannot honor. mcp.WithString("code", - mcp.Required(), mcp.Description("JavaScript source code to execute."), ), + mcp.WithString("script", + mcp.Description("Name of a stored script to execute."), + ), ) return []mcpserver.ServerTool{{ Tool: codeExecutionTool, - Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return mcp.NewToolResultError("Code execution is disabled. Enable it by setting \"enable_code_execution\": true in your mcpproxy configuration file."), nil + Handler: func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Same wording, same typed identity as the handler-level gate: + // which surface refused must not change what the caller is told. + recordCodeExecRefusal(ctx, config.ErrCodeExecutionDisabled) + return mcp.NewToolResultError(config.CodeExecutionDisabledMessage), nil }, }} } @@ -509,10 +519,14 @@ func (p *MCPProxyServer) buildCodeExecutionTool() []mcpserver.ServerTool { mcp.WithDestructiveHintAnnotation(true), mcp.WithReadOnlyHintAnnotation(false), mcp.WithOpenWorldHintAnnotation(true), + // Spec 097: optional `code` + optional `script`; the handler enforces + // the exactly-one-of rule (see mcp.go for the same shape). mcp.WithString("code", - mcp.Required(), mcp.Description(codeExecutionCodeDescription), ), + mcp.WithString("script", + mcp.Description(codeExecutionScriptDescription), + ), mcp.WithString("language", mcp.Description(codeExecutionLanguageDescription), mcp.Enum("javascript", "typescript"), diff --git a/internal/server/server.go b/internal/server/server.go index 24c36a0cd..137b45875 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -225,6 +225,9 @@ func NewServerWithConfigPath(cfg *config.Config, configPath string, logger *zap. cfg.DebugSearch, cfg, rt.SignatureCache(), // Spec 085 FR-008: the ONE Runtime-owned signature cache + // Spec 097: the daemon's stored-script authority is the config FILE it + // actually loaded, declared here rather than re-derived per request. + WithConfigFilePath(server.GetConfigPath()), ) // MCP-32: give the MCP proxy access to observability for tool-call metrics // and OTLP spans. diff --git a/oas/docs.go b/oas/docs.go index a905a89bf..445c93dde 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -9,7 +9,7 @@ const docTemplate = `{ "components": {"schemas":{"config.ConcurrencyDefaults":{"description":"ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server\ndefault set inherited by every server that does not override a setting.\nAbsent (the default) = no per-server limiting unless a server configures\nit explicitly. File/API-configured only — no env scheme (FR-022).","properties":{"max_concurrent_requests":{"type":"integer"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"}},"type":"object"},"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_max_size_mb":{"description":"Max total activity-log size in MB before pruning oldest (default: 256, 0=disabled)","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"allow_private_registry_fetch":{"description":"AllowPrivateRegistryFetch opts out of the registry SSRF guard (MCP-1076,\nCWE-918). By default (false) registry fetches refuse any host that is — or\nresolves to — a non-routable address (loopback, RFC1918/CGNAT private,\nlink-local incl. the 169.254.169.254 cloud-metadata endpoint), so a\nmalicious or typo'd registry source cannot turn the daemon into a\nrequest-forgery vector against internal services.\n\nThis opt-out is BLANKET (all-or-nothing): setting it true disables the\nguard for EVERY non-routable range at once — loopback, RFC1918/CGNAT\nprivate, link-local AND the 169.254.169.254 cloud-metadata endpoint. There\nis no way to allow only loopback; enabling it for a localhost dev registry\nalso re-opens the cloud-metadata SSRF vector. Set true ONLY when you\nintentionally run a trusted registry mirror on an internal/private address,\nideally on a host with no cloud-metadata exposure. The change takes effect\nonly on daemon (re)start or config reload.","type":"boolean"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"api_key":{"description":"Security settings","type":"string"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_parallel":{"description":"Default concurrency for call_tools() batches (1-32, default: 8)","type":"integer"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"disable_management":{"type":"boolean"},"docker_isolation":{"$ref":"#/components/schemas/config.DockerIsolationConfig"},"docker_recovery":{"$ref":"#/components/schemas/config.DockerRecoveryConfig"},"enable_code_execution":{"description":"Code execution settings","type":"boolean"},"enable_prompts":{"description":"Prompts settings","type":"boolean"},"enable_socket":{"description":"Enable Unix socket/named pipe for local IPC (default: true)","type":"boolean"},"enable_tray":{"description":"Deprecated: EnableTray is unused and has no runtime effect. Kept for backward compatibility.","type":"boolean"},"environment":{"$ref":"#/components/schemas/secureenv.EnvConfig"},"features":{"$ref":"#/components/schemas/config.FeatureFlags"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"http_idle_timeout":{"description":"HTTPIdleTimeout caps how long an idle keep-alive connection is kept open.\nUnset = 180s. \"0s\" removes the dedicated idle deadline, but net/http then\nfalls back to ReadTimeout — idle is fully unbounded only when\nhttp_read_timeout is also \"0s\". Requires a restart.","type":"string"},"http_read_timeout":{"description":"HTTPReadTimeout caps how long reading a whole request (headers + body)\nmay take. Unset = 120s; \"0s\" disables it. Requires a restart.","type":"string"},"http_write_timeout":{"description":"HTTPWriteTimeout caps how long producing a whole response may take on\nnon-streaming endpoints (REST, Web UI, health). Unset = 120s; \"0s\"\ndisables it globally. MCP and SSE /events routes are exempt by design.","type":"string"},"init_timeout":{"description":"InitTimeout is the global default deadline for an upstream's MCP\n` + "`" + `initialize` + "`" + ` handshake (MCP-3322 / GH #760). *Duration tri-state: nil =\ninherit the built-in 30s default; a positive value = that deadline. A\nper-server InitTimeout overrides this. Resolved by ResolveInitTimeout;\nvalidated to {0} ∪ [1s, 30m] in Validate(). Servers doing legitimate\nfirst-run warmup (cache/index build) before answering ` + "`" + `initialize` + "`" + ` can\nraise this so they are not killed mid-startup.","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_concurrent_requests":{"description":"Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL\nAGGREGATE limiter — one proxy-wide cap on concurrently running upstream\ntool calls, with its own bounded wait queue. Tri-state pointers: absent =\nthe limiter does not exist (default, zero behavior change); an explicit 0\nmax also disables it; positive = that cap. This scope is NEVER a\nper-server inheritance source — per-server values come from\nServerConcurrencyDefaults / the per-server overrides — but a server's\neffective concurrency is bounded by BOTH its own limiter and this one.\nResolved by ResolveGlobalConcurrency; hot-reloadable; overridable via\nMCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).","type":"integer"},"max_result_size_chars":{"description":"Advertised on every tool as ` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; raises Claude Code's inline-response ceiling from 50k to up to 500k chars. Set to 0 to disable.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of the\nsecret-bearing server fields — sensitive header values (Authorization,\nX-API-Key, Cookie, …), env-var secrets, and URL query credentials — in\nresponses from the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + `\nREST API, and the SSE event stream. It also lets URL secrets echoed\ninto last_error / health.detail through unscrubbed.\n\nDefault false — sensitive values are surfaced masked as\n` + "`" + `••••\u003clast2\u003e (\u003cN\u003e chars)` + "`" + ` (error strings use ` + "`" + `***REDACTED***` + "`" + `) so an\nMCP agent cannot read Bearer tokens / API keys / URL secrets out of\nanother upstream's config (PR #425, issue #872). ${env:…}/${keyring:…}\nreferences are labels, not secrets, and pass through unchanged.\n\nThe Web UI / macOS tray edit forms work without seeing the real\nvalues: PATCH /api/v1/servers/{id} deep-merges (omitted keys are\npreserved, see ` + "`" + `headers_remove` + "`" + ` / ` + "`" + `env_remove` + "`" + ` for explicit\ndeletes), so clients compute a diff and only send the keys that\nactually changed. Redacted-but-unchanged values never round-trip\n— the backend keeps the real string. Set this to true if a\ndownstream tool genuinely needs raw values in the response.","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"security":{"$ref":"#/components/schemas/config.SecurityConfig"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"server_concurrency_defaults":{"$ref":"#/components/schemas/config.ConcurrencyDefaults"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_discovery_interval":{"type":"string"},"tool_response_limit":{"type":"integer"},"tool_response_mode":{"description":"Tool response mode (Spec 085): how retrieve_tools serializes results.\nValid values: \"\" (= full), \"full\" (default: today's schema-bearing\nentries), \"compact\" (signature + first-sentence entries). Orthogonal to\nrouting_mode — routing_mode selects the tool SURFACE, this selects the\nSERIALIZATION within the retrieve_tools surface. Serialization-only: it\nnever affects the query, ranking, or result set. Hot-reloadable.","type":"string"},"tool_response_session_risk_warning":{"description":"ToolResponseSessionRiskWarning controls whether the prose ` + "`" + `warning` + "`" + ` field\nis included in the ` + "`" + `session_risk` + "`" + ` object returned by ` + "`" + `retrieve_tools` + "`" + `.\nThe structured fields (level, lethal_trifecta, has_open_world_tools, etc.)\nare always included. Default: false (quiet for LLM clients) — see issue #406.\nMost tools lack annotations, so the MCP-spec defaults treat them as fully\npermissive across all three risk axes, which makes the prose warning fire\non almost every call and wastes tokens.","type":"boolean"},"tools_limit":{"type":"integer"},"toon_min_savings_pct":{"description":"ToonMinSavingsPct is the minimum byte-savings percentage (validated\n1-90; 0/unset → 15) the complete TOON emission (marker + hint + body)\nmust achieve over the exact passthrough emission for adaptive mode to\nencode a block. Byte savings approximate token savings for the tabular\npayload class; the spec-083 profiler reports true token deltas.\nGlobal-only (no per-server override, FR-001).","type":"integer"},"toon_output":{"description":"ToonOutput selects the TOON encoding mode for call_tool_* result text\nblocks (spec 084): \"off\" (default — responses byte-identical to\npre-feature behavior), \"adaptive\" (encode only tabular-uniform payloads\nthat beat compact JSON by ToonMinSavingsPct), or \"always\"\n(benchmark/debug only — encodes every JSON-parseable block and can\nINCREASE token cost). Per-server override: ServerConfig.ToonOutput.\nResolved by ResolveToonOutput; hot-reloadable.","type":"string"},"top_k":{"description":"Deprecated: TopK is superseded by ToolsLimit and has no runtime effect. Kept for backward compatibility.","type":"integer"},"tray_endpoint":{"description":"Tray endpoint override (unix:// or npipe://)","type":"string"},"trusted_hosts":{"description":"TrustedHosts lists non-loopback Host header values accepted on loopback\nlisteners (GH #898). DNS-rebinding protection rejects requests whose Host\nheader is not a loopback address when mcpproxy listens on loopback; a\nreverse proxy (nginx → 127.0.0.1) forwarding the public domain in Host\ntrips it. Entries are hostnames, case-insensitive; an entry without a\nport matches any port, with a port it must match exactly; a leading dot\n(\".example.com\") is a subdomain wildcard. The single entry \"*\" disables\nHost and Origin validation entirely. The same list also validates the\nOrigin header when present (MCP spec DNS-rebinding defense). Empty\n(default) keeps full protection. Env override: MCPPROXY_TRUSTED_HOSTS\n(comma-separated).","items":{"type":"string"},"type":"array","uniqueItems":false},"update_check":{"$ref":"#/components/schemas/config.UpdateCheckConfig"}},"type":"object"},"config.CustomPattern":{"properties":{"category":{"description":"Category (defaults to \"custom\")","type":"string"},"keywords":{"description":"Keywords to match (mutually exclusive with Regex)","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Unique identifier for this pattern","type":"string"},"regex":{"description":"Regex pattern (mutually exclusive with Keywords)","type":"string"},"severity":{"description":"Risk level: critical, high, medium, low","type":"string"}},"type":"object"},"config.DeepScanConfig":{"description":"DeepScan is the opt-in \"deep scan\" layer (Spec 077 US3). It subsumes the\ndeprecated top-level scanner_fetch_package_source / scanner_disable_no_new_privileges\nkeys (migrated on load) and gates the heavy Docker-based scanners + source\nextraction. Disabled by default (FR-006): only the deterministic in-process\nbaseline scanner runs. A deep-scan failure NEVER changes the baseline verdict\n(FR-007/FR-008).","properties":{"disable_no_new_privileges":{"description":"DisableNoNewPrivileges, when true, omits the ` + "`" + `--security-opt\nno-new-privileges` + "`" + ` flag from scanner container runs (snap-docker/AppArmor\nescape hatch). Absorbs the deprecated top-level\nscanner_disable_no_new_privileges. Default false.","type":"boolean"},"enabled":{"description":"Enabled is the master opt-in for the heavy layer (FR-006). Default false.","type":"boolean"},"fetch_package_source":{"description":"FetchPackageSource controls whether the scanner fetches the PUBLISHED\nsource of package-runner servers (npx/uvx) — without executing it — when\nno local source is available. Absorbs the deprecated top-level\nscanner_fetch_package_source. Default (nil) is ENABLED within deep scan.","type":"boolean"},"scanners":{"description":"Scanners optionally restricts which deep scanners may run under the\numbrella (by scanner id). Empty ⇒ all enabled deep scanners are eligible.","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.DockerIsolationConfig":{"description":"Docker isolation settings","properties":{"cpu_limit":{"description":"CPU limit for containers","type":"string"},"default_images":{"additionalProperties":{"type":"string"},"description":"Map of runtime type to Docker image","type":"object"},"enable_cache_volume":{"description":"Mount shared cache volumes for faster restarts (default: true)","type":"boolean"},"enabled":{"description":"Global enable/disable for Docker isolation (legacy; superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments","items":{"type":"string"},"type":"array","uniqueItems":false},"log_driver":{"description":"Docker log driver (default: json-file)","type":"string"},"log_max_files":{"description":"Maximum number of log files (default: 3)","type":"string"},"log_max_size":{"description":"Maximum size of log files (default: 100m)","type":"string"},"memory_limit":{"description":"Memory limit for containers","type":"string"},"mode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"network_mode":{"description":"Docker network mode (default: bridge)","type":"string"},"registry":{"description":"Custom registry (defaults to docker.io)","type":"string"},"timeout":{"description":"Container startup timeout","type":"string"}},"type":"object"},"config.DockerRecoveryConfig":{"description":"Docker recovery settings","properties":{"enabled":{"description":"Enable Docker recovery monitoring (default: true)","type":"boolean"},"max_retries":{"description":"Maximum retry attempts (0 = unlimited)","type":"integer"},"notify_on_failure":{"description":"Show notification on recovery failure (default: true)","type":"boolean"},"notify_on_retry":{"description":"Show notification on each retry (default: false)","type":"boolean"},"notify_on_start":{"description":"Show notification when recovery starts (default: true)","type":"boolean"},"notify_on_success":{"description":"Show notification on successful recovery (default: true)","type":"boolean"},"persistent_state":{"description":"Save recovery state across restarts (default: true)","type":"boolean"}},"type":"object"},"config.FeatureFlags":{"description":"Deprecated: Features flags are unused and have no runtime effect. Kept for backward compatibility.","properties":{"enable_async_storage":{"type":"boolean"},"enable_caching":{"type":"boolean"},"enable_contract_tests":{"type":"boolean"},"enable_debug_logging":{"description":"Development features","type":"boolean"},"enable_docker_isolation":{"type":"boolean"},"enable_event_bus":{"type":"boolean"},"enable_health_checks":{"type":"boolean"},"enable_metrics":{"type":"boolean"},"enable_oauth":{"description":"Security features","type":"boolean"},"enable_observability":{"description":"Observability features","type":"boolean"},"enable_quarantine":{"type":"boolean"},"enable_runtime":{"description":"Runtime features","type":"boolean"},"enable_search":{"description":"Storage features","type":"boolean"},"enable_sse":{"type":"boolean"},"enable_tracing":{"type":"boolean"},"enable_tray":{"type":"boolean"},"enable_web_ui":{"description":"UI features","type":"boolean"}},"type":"object"},"config.IntentDeclarationConfig":{"description":"Intent declaration settings (Spec 018)","properties":{"strict_server_validation":{"description":"StrictServerValidation controls whether server annotation mismatches\ncause rejection (true) or just warnings (false).\nDefault: true (reject mismatches)","type":"boolean"}},"type":"object"},"config.IsolationConfig":{"description":"Per-server isolation settings","properties":{"enabled":{"description":"Enable Docker isolation for this server (nil = inherit global; legacy, superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments for this server","items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"description":"Custom Docker image (overrides default)","type":"string"},"log_driver":{"description":"Docker log driver override for this server","type":"string"},"log_max_files":{"description":"Maximum number of log files override","type":"string"},"log_max_size":{"description":"Maximum size of log files override","type":"string"},"mode":{"$ref":"#/components/schemas/config.IsolationMode"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"config.IsolationMode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"config.LogConfig":{"description":"Logging configuration","properties":{"compress":{"type":"boolean"},"enable_console":{"type":"boolean"},"enable_file":{"type":"boolean"},"filename":{"type":"string"},"json_format":{"type":"boolean"},"level":{"type":"string"},"log_dir":{"description":"Custom log directory","type":"string"},"max_age":{"description":"days","type":"integer"},"max_backups":{"description":"number of backup files","type":"integer"},"max_size":{"description":"MB","type":"integer"}},"type":"object"},"config.MetricsExporterConfig":{"description":"Metrics gates the Prometheus /metrics scrape endpoint (MCP-32). Disabled\nby default — operators opt in for k8s/enterprise deployments.","properties":{"enabled":{"description":"Enabled exposes /metrics on the existing HTTP listener when true.","type":"boolean"}},"type":"object"},"config.OAuthConfig":{"description":"OAuth configuration (keep even when empty to signal OAuth requirement)","properties":{"client_id":{"type":"string"},"client_secret":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters (e.g., RFC 8707 resource)","type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_uri":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ObservabilityConfig":{"description":"Observability settings (Spec 069): usage aggregate cache/persistence cadence.","properties":{"metrics":{"$ref":"#/components/schemas/config.MetricsExporterConfig"},"tracing":{"$ref":"#/components/schemas/config.TracingExporterConfig"},"usage_cache_ttl":{"description":"UsageCacheTTL bounds the freshness of the usage endpoint's read cache for\nwide windows (FR-005). Default 5s.","type":"string"},"usage_persist_interval":{"description":"UsagePersistInterval is how often the actor-owned usage aggregate snapshot\nis flushed to storage. Default 30s.","type":"string"}},"type":"object"},"config.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.ProfileConfig":{"properties":{"name":{"description":"URL slug, validated","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"deep_scan":{"$ref":"#/components/schemas/config.DeepScanConfig"},"integrity_check_interval":{"type":"string"},"integrity_check_on_restart":{"type":"boolean"},"runtime_read_only":{"type":"boolean"},"runtime_tmpfs_size":{"type":"string"},"scan_timeout_default":{"type":"string"},"scanner_disable_no_new_privileges":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.DisableNoNewPrivileges\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.IsDisableNoNewPrivileges. Cleared after migration.\n\nScannerDisableNoNewPrivileges, when true, omits the\n` + "`" + `--security-opt no-new-privileges` + "`" + ` flag from scanner container runs.\n\nBackground: snap-installed Docker on Ubuntu confines dockerd under the\n` + "`" + `snap.docker.dockerd` + "`" + ` AppArmor profile. When runc tries to transition\nthe container into the inner ` + "`" + `docker-default` + "`" + ` profile to exec the\nentrypoint, AppArmor refuses the transition because NO_NEW_PRIVS\nforbids privilege/profile changes on exec — the result is EPERM\n(\"operation not permitted\") and every scanner fails immediately.\n\nSet this to true ONLY on hosts hitting that incompatibility. Scanner\ncontainers still run with read-only rootfs, tmpfs /tmp, no-network by\ndefault, and read-only source mounts, so the marginal isolation loss\nis small. The preferred fix remains replacing snap docker with a\ndistro-packaged docker.","type":"boolean"},"scanner_fetch_package_source":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.FetchPackageSource\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.EffectiveFetchPackageSource. Cleared after migration.\n\nScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"},"tpa_bundle_path":{"description":"TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json\nthe offline TPA scanner runs (spec 086 FR-019: the signature-DB location\nMUST be configuration-driven, not hardcoded). Empty (the default) runs the\ncorpus embedded in this build.\n\nEnv override: MCPPROXY_TPA_BUNDLE_PATH. Hot-reloadable — the path is\nre-read on every config.reloaded event via\nscanner.Service.ApplySecurityConfig, so a corpus refresh needs no restart.\nA configured bundle that fails to read/parse/version-check/compile is\nREFUSED and the previously active corpus stays live (fail-closed, never\nfail-empty); the reason is logged and surfaced in the security overview's\nsignature_bundle.load_error.","type":"string"}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"init_timeout":{"description":"InitTimeout overrides the global init_timeout for this server's MCP\n` + "`" + `initialize` + "`" + ` handshake deadline (MCP-3322 / GH #760). *Duration tri-state:\nnil = inherit the global value (or 30s default), positive = that deadline.\nResolved by Config.ResolveInitTimeout; validated to {0} ∪ [1s, 30m]. Raise\nthis for upstreams that do legitimate first-run warmup (e.g. caching many\nchannels/users) before responding to ` + "`" + `initialize` + "`" + `.","type":"string"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"launcher_wait_timeout":{"description":"LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched\nHTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted\nwhen the server is configured with both Command and an HTTP/SSE URL — i.e.,\nmcpproxy starts the process AND connects via network. Stdio servers ignore\nthis field. Zero or unset → 30s default.","type":"string"},"max_concurrent_requests":{"description":"Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).\nTri-state per setting, exactly like HealthCheckInterval: absent = inherit\nthe per-server default set (server_concurrency_defaults), explicit 0 =\ndisable that setting for this server (0 max = no per-server limiter at\nall; 0 queue_size = no pending capacity, shed immediately at the cap),\npositive = override. The global aggregate limiter is never inherited from\nhere — it applies on top, so effective concurrency is min(per-server,\nglobal). Resolved by Config.ResolveServerConcurrency.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/config.OAuthConfig"},"protocol":{"description":"stdio, http, sse, streamable-http, auto","type":"string"},"quarantined":{"description":"Security quarantine status","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets a disconnected server","type":"boolean"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"toon_output":{"description":"ToonOutput overrides the global toon_output mode for this server's\ntools (spec 084, FR-001). Plain string, not a pointer: \"\"/absent =\ninherit the global value; \"off\"|\"adaptive\"|\"always\" = override (\"off\"\nis the explicit force-off). Resolved by Config.ResolveToonOutput.","type":"string"},"trust_mode":{"description":"TrustMode is the per-server trust tier: auto|scan|manual. Supersedes\nauto_approve_tool_changes (spec 086). An empty value is derived from the\nlegacy fields at load via normalizeServerQuarantineFlags; the single\nresolution point is EffectiveTrustMode(), which treats an empty or\nunrecognized value as manual (secure by default). Read via\nEffectiveTrustMode(), never the raw string.","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TelemetryConfig":{"description":"Telemetry settings (Spec 036)","properties":{"anonymous_id":{"description":"Auto-generated UUIDv4","type":"string"},"anonymous_id_created_at":{"description":"Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"},"last_reported_version":{"description":"Upgrade funnel","type":"string"},"last_startup_outcome":{"description":"success|port_conflict|db_locked|...","type":"string"},"notice_shown":{"description":"First-run notice flag","type":"boolean"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"config.TracingExporterConfig":{"description":"Tracing gates the OpenTelemetry OTLP trace exporter (MCP-32). Disabled by\ndefault.","properties":{"enabled":{"description":"Enabled turns on OTLP trace export for tool calls and upstream hops.","type":"boolean"},"endpoint":{"description":"Endpoint is the collector address as host:port (no scheme), e.g.\n\"localhost:4318\" for http or \"localhost:4317\" for grpc.","type":"string"},"protocol":{"description":"Protocol selects the OTLP transport: \"http\" or \"grpc\".","type":"string"},"sample_rate":{"description":"SampleRate is the head-based trace sampling ratio in [0,1]. Default 0.1.","type":"number"}},"type":"object"},"config.UpdateCheckConfig":{"description":"Update-check settings (Spec 079 FR-012): config-file control of the\nbackground upgrade-awareness checker (internal/updatecheck). nil =\nenabled on the stable channel (existing default behavior). The existing\nenvironment switches keep working and WIN over these keys (FR-014):\nMCPPROXY_DISABLE_AUTO_UPDATE=true force-disables even when\nenabled=true, and MCPPROXY_ALLOW_PRERELEASE_UPDATES=true force-selects\nthe rc channel even when channel=stable.","properties":{"channel":{"description":"Channel selects which releases are offered as updates: \"stable\"\n(default; prereleases never offered) or \"rc\" (prereleases included).\nEmpty resolves to stable. Validated in ValidateDetailed.\n\nNOTE: for a RELEASED build the running binary's own version is\nauthoritative and overrides this field — a stable build is never\noffered an RC (even with channel=rc), and an RC build always tracks the\nrc channel. This field only takes effect on dev/unstamped builds. See\ninternal/updatecheck.Checker.IncludePrereleases.","type":"string"},"enabled":{"description":"Enabled gates all update checking. Tri-state: nil/absent = enabled\n(default true, matching pre-079 behavior). When false, no network\ncheck is performed and no upgrade nudge appears on any surface\n(FR-015) — /api/v1/info omits the update object entirely.","type":"boolean"}},"type":"object"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\"","type":"string"}},"type":"object"},"connect.ConnectResult":{"description":"The full result; its action mirrors the top-level one","properties":{"action":{"description":"\"created\", \"updated\", \"already_exists\", \"removed\", \"not_found\"","type":"string"},"backup_path":{"type":"string"},"client":{"type":"string"},"config_path":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"arguments":{"description":"Tool call arguments","type":"object"},"detection_types":{"description":"List of detection types found","items":{"type":"string"},"type":"array","uniqueItems":false},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP transport session ID (regenerated on every reconnect)","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\", \"rejected\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"},"work_session_id":{"description":"Spec 082: one client, one project, across reconnects","type":"string"}},"type":"object"},"contracts.ActivitySource":{"description":"How activity was triggered: \"mcp\", \"cli\", \"api\"","type":"string","x-enum-varnames":["ActivitySourceMCP","ActivitySourceCLI","ActivitySourceAPI"]},"contracts.ActivitySummaryResponse":{"properties":{"blocked_count":{"description":"Count of blocked activities","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"rejected_count":{"description":"RejectedCount is the number of calls shed by a concurrency limiter before\nthey reached an upstream (spec 093). Counted separately from errors: it is\nproxy backpressure, not an upstream fault, and it is the signal an\noperator right-sizes max_concurrent_requests against.","type":"integer"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"contracts.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeepScanDescriptor":{"description":"DeepScan reports the opt-in \"deep scan\" layer status (Spec 077 US3),\nSEPARATELY from the baseline verdict above. Always emitted on a computed\nsummary — when deep scan is off (the default) it reports enabled=false\nplus any enabled-but-skipped Docker scanners. It never influences Status.","properties":{"available":{"type":"boolean"},"enabled":{"type":"boolean"},"ran":{"type":"boolean"},"scanners_failed":{"items":{"$ref":"#/components/schemas/contracts.DeepScanScannerFailure"},"type":"array","uniqueItems":false},"skipped_scanners":{"description":"SkippedScanners lists Docker scanners the user enabled that are skipped\nbecause security.deep_scan.enabled is false (informational).","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DeepScanScannerFailure":{"properties":{"id":{"type":"string"},"reason":{"type":"string"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostic":{"description":"Spec 044 — structured diagnostic error and stable error code. Both\nare populated when the server is in a failed state and the error\nhas been classified by internal/diagnostics. Healthy servers omit\nthese fields.","properties":{"cause":{"type":"string"},"code":{"type":"string"},"detected_at":{"type":"string"},"docs_url":{"type":"string"},"fix_steps":{"items":{"$ref":"#/components/schemas/contracts.DiagnosticFixStep"},"type":"array","uniqueItems":false},"severity":{"type":"string"},"user_message":{"type":"string"}},"type":"object"},"contracts.DiagnosticFixStep":{"properties":{"command":{"type":"string"},"destructive":{"type":"boolean"},"fixer_key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.EditRegistrySourceRequest":{"properties":{"name":{"description":"new display name","type":"string"},"servers_url":{"description":"explicit servers-collection URL","type":"string"},"url":{"description":"new base/servers https URL","type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.FindingCounts":{"properties":{"dangerous":{"description":"Tool poisoning, active prompt injection","type":"integer"},"info":{"description":"Low-severity CVEs, informational","type":"integer"},"total":{"type":"integer"},"warning":{"description":"Rug pull, supply chain CVEs with exploits","type":"integer"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object","type":"object"},"config_path":{"description":"Path to config file","type":"string"}},"type":"object"},"contracts.GetRegistriesResponse":{"properties":{"registries":{"items":{"$ref":"#/components/schemas/contracts.Registry"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerLogsResponse":{"properties":{"count":{"type":"integer"},"logs":{"items":{"$ref":"#/components/schemas/contracts.LogEntry"},"type":"array","uniqueItems":false},"server_name":{"type":"string"}},"type":"object"},"contracts.GetServerToolCallsResponse":{"properties":{"server_name":{"type":"string"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerToolsResponse":{"properties":{"count":{"type":"integer"},"server_name":{"type":"string"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GetServersResponse":{"properties":{"servers":{"items":{"$ref":"#/components/schemas/contracts.Server"},"type":"array","uniqueItems":false},"stats":{"$ref":"#/components/schemas/contracts.ServerStats"}},"type":"object"},"contracts.GetSessionDetailResponse":{"properties":{"session":{"$ref":"#/components/schemas/contracts.MCPSession"}},"type":"object"},"contracts.GetSessionsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"sessions":{"items":{"$ref":"#/components/schemas/contracts.MCPSession"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetToolCallDetailResponse":{"properties":{"tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"}},"type":"object"},"contracts.GetToolCallsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GlobalToolsResponse":{"properties":{"failed_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"partial":{"type":"boolean"},"stats":{"$ref":"#/components/schemas/contracts.GlobalToolsStats"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GlobalToolsStats":{"properties":{"disabled":{"type":"integer"},"enabled":{"type":"integer"},"pending_approval":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.HealthStatus":{"description":"Unified health status calculated by the backend","properties":{"action":{"description":"Action is the suggested fix action: \"login\", \"restart\", \"enable\", \"approve\", \"view_logs\", \"set_secret\", \"configure\", or \"\" (none)","type":"string"},"admin_state":{"description":"AdminState indicates the admin state: \"enabled\", \"disabled\", or \"quarantined\"","type":"string"},"detail":{"description":"Detail is an optional longer explanation of the status","type":"string"},"level":{"description":"Level indicates the health level: \"healthy\", \"degraded\", or \"unhealthy\"","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"}},"type":"object"},"contracts.InfoEndpoints":{"description":"Available API endpoints","properties":{"http":{"description":"HTTP endpoint address (e.g., \"127.0.0.1:8080\")","type":"string"},"socket":{"description":"Unix socket path (empty if disabled)","type":"string"}},"type":"object"},"contracts.InfoResponse":{"properties":{"endpoints":{"$ref":"#/components/schemas/contracts.InfoEndpoints"},"launched_by":{"description":"LaunchedBy is the durable launch provenance of the running core (Spec\n092 FR-001a): \"tray\" when a tray spawned it, \"installer\" when the macOS\nPKG postinstall did, \"\" when user-launched or unknown. Always present\n(possibly empty) so a tray can distinguish \"old core, not mine\" from\n\"old core I may supersede\".","type":"string"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"pid":{"description":"PID is the operating-system process id of the running core (Spec 092\nFR-002). A tray that merely ATTACHED to a core holds no Process handle\nfor it, so without this there is no mechanism at all to stop a stale\ncore — the consent action would have nothing to act on and could only\nprint instructions. Paired with LaunchedBy it is what lets a newer tray\nsupersede a core an older tray started.","type":"integer"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"update_policy":{"$ref":"#/components/schemas/contracts.UpdatePolicy"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"network_mode":{"type":"string"},"timeout":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationDefaults":{"description":"IsolationDefaults exposes the resolved baseline values that\nwould apply when no per-server override is set. Populated on\nlist/get responses; never consumed on PATCH requests.","properties":{"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"runtime_type":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.LogEntry":{"properties":{"fields":{"type":"object"},"level":{"type":"string"},"message":{"type":"string"},"server":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.MCPSession":{"properties":{"client_name":{"type":"string"},"client_version":{"type":"string"},"end_time":{"type":"string"},"experimental":{"items":{"type":"string"},"type":"array","uniqueItems":false},"has_roots":{"description":"MCP Client Capabilities","type":"boolean"},"has_sampling":{"type":"boolean"},"id":{"type":"string"},"last_activity":{"type":"string"},"start_time":{"type":"string"},"status":{"type":"string"},"tool_call_count":{"type":"integer"},"total_tokens":{"type":"integer"},"work_session_id":{"type":"string"},"workspace_name":{"description":"Workspace / work session (Spec 082). WorkspaceName is the project's\nbasename — the full local path is never exposed. WorkSessionID groups the\nreconnects that make up one stretch of user work.","type":"string"}},"type":"object"},"contracts.MetadataStatus":{"properties":{"authorization_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"error":{"type":"string"},"found":{"type":"boolean"},"url_checked":{"type":"string"}},"type":"object"},"contracts.MissingSecretInfo":{"properties":{"secret_name":{"type":"string"},"used_by":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.NPMPackageInfo":{"properties":{"exists":{"type":"boolean"},"install_cmd":{"type":"string"}},"type":"object"},"contracts.OAuthConfig":{"properties":{"auth_url":{"type":"string"},"client_id":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_port":{"type":"integer"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_expires_at":{"description":"When the OAuth token expires","type":"string"},"token_url":{"type":"string"},"token_valid":{"description":"Whether token is currently valid","type":"boolean"}},"type":"object"},"contracts.OAuthErrorDetails":{"description":"Structured discovery/failure details","properties":{"authorization_server_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"dcr_status":{"$ref":"#/components/schemas/contracts.DCRStatus"},"protected_resource_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"server_url":{"type":"string"}},"type":"object"},"contracts.OAuthFlowError":{"properties":{"correlation_id":{"description":"Flow tracking ID for log correlation","type":"string"},"debug_hint":{"description":"CLI command for log lookup","type":"string"},"details":{"$ref":"#/components/schemas/contracts.OAuthErrorDetails"},"error_code":{"description":"Machine-readable error code (e.g., OAUTH_NO_METADATA)","type":"string"},"error_type":{"description":"Category of OAuth runtime failure","type":"string"},"message":{"description":"Human-readable error description","type":"string"},"request_id":{"description":"HTTP request ID (from PR #237)","type":"string"},"server_name":{"description":"Server that failed OAuth","type":"string"},"success":{"description":"Always false","type":"boolean"},"suggestion":{"description":"Actionable remediation hint","type":"string"}},"type":"object"},"contracts.OAuthIssue":{"properties":{"documentation_url":{"type":"string"},"error":{"type":"string"},"issue":{"type":"string"},"missing_params":{"items":{"type":"string"},"type":"array","uniqueItems":false},"resolution":{"type":"string"},"server_name":{"type":"string"}},"type":"object"},"contracts.OAuthRequirement":{"properties":{"expires_at":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"state":{"type":"string"}},"type":"object"},"contracts.OAuthStartResponse":{"properties":{"auth_url":{"description":"Authorization URL (always included for manual use)","type":"string"},"browser_error":{"description":"Error message if browser launch failed","type":"string"},"browser_opened":{"description":"Whether browser launch succeeded","type":"boolean"},"correlation_id":{"description":"UUID for tracking this flow","type":"string"},"message":{"description":"Human-readable status message","type":"string"},"server_name":{"description":"Name of the server being authenticated","type":"string"},"success":{"description":"Always true for successful start","type":"boolean"}},"type":"object"},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"blocked_count":{"description":"Number of disabled (blocked) tools","type":"integer"},"changed_count":{"description":"Number of tools whose description/schema changed since approval","type":"integer"},"pending_count":{"description":"Number of newly discovered tools awaiting approval","type":"integer"}},"type":"object"},"contracts.RefreshRegistryResponse":{"properties":{"cleared":{"description":"number of cached entries dropped","type":"integer"},"registry_id":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"deep_scan":{"$ref":"#/components/schemas/contracts.DeepScanDescriptor"},"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary (baseline) scan pass — informational only.\nSpec 077 US3 (FR-008/FR-014): Status is derived SOLELY from the\ndeterministic baseline findings; a failed Docker deep scanner no longer\ndowngrades a clean verdict. That failure is surfaced via DeepScan instead.","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"init_timeout":{"description":"InitTimeout mirrors config.ServerConfig.InitTimeout (MCP-3322 / GH #760):\nthe per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override. Serialized as\na duration string (e.g. \"120s\"); nil/omitted means \"inherit the global\ndefault\". Surfaced on the GET path so clients can read back a configured\noverride; PATCH/POST accept it via AddServerRequest.","type":"string"},"isolation":{"$ref":"#/components/schemas/contracts.IsolationConfig"},"isolation_defaults":{"$ref":"#/components/schemas/contracts.IsolationDefaults"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"max_concurrent_requests":{"description":"Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of\nFR-020. Each setting is tri-state: nil (omitted) means \"inherit\nserver_concurrency_defaults\", 0 disables that setting for this server,\npositive overrides it. Surfaced on the GET path so a caller can read back\nwhat it set; PATCH/POST accept them via AddServerRequest. The effective\nconcurrency for a server is additionally bounded by the global aggregate\nlimiter, which is NOT an inheritance source for these fields.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"security_scan":{"$ref":"#/components/schemas/contracts.SecurityScanSummary"},"should_retry":{"type":"boolean"},"source_registry_id":{"description":"MCP-901 — registry provenance of an upstream that was added from a\nregistry. SourceRegistryID names the source registry (empty for\nmanually-configured servers); SourceRegistryProvenance is the trust tag\nrecorded at add time (\"official/trusted\" or \"custom/unverified\"). Both\nare projected from config.ServerConfig so the approval/quarantine view\ncan render an \"added from \u003cregistry\u003e · unverified\" origin badge. Optional\nand omitted when empty — clients that pre-date this treat them as absent.","type":"string"},"source_registry_provenance":{"type":"string"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"trust_mode":{"description":"TrustMode mirrors config.ServerConfig.TrustMode (spec 086): the per-server\ntrust tier (\"auto\"/\"scan\"/\"manual\"). Surfaced on the GET path so clients can\nread back the persisted mode; PATCH/POST accept it via AddServerRequest.\nOmitted when empty (server predates the field / relies on legacy flags).","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"user_logged_out":{"description":"True if user explicitly logged out (prevents auto-reconnection)","type":"boolean"},"working_dir":{"type":"string"}},"type":"object"},"contracts.ServerActionResponse":{"properties":{"action":{"type":"string"},"async":{"type":"boolean"},"server":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ServerStats":{"properties":{"connected_servers":{"type":"integer"},"docker_containers":{"type":"integer"},"quarantined_servers":{"type":"integer"},"token_metrics":{"$ref":"#/components/schemas/contracts.ServerTokenMetrics"},"total_servers":{"type":"integer"},"total_tools":{"type":"integer"}},"type":"object"},"contracts.ServerTokenMetrics":{"properties":{"average_query_result_size":{"description":"Typical retrieve_tools output (tokens)","type":"integer"},"per_server_tool_list_sizes":{"additionalProperties":{"type":"integer"},"description":"Token size per server","type":"object"},"saved_tokens":{"description":"Difference","type":"integer"},"saved_tokens_percentage":{"description":"Percentage saved","type":"number"},"total_server_tool_list_size":{"description":"All upstream tools combined (tokens)","type":"integer"}},"type":"object"},"contracts.SuccessResponse":{"properties":{"data":{"type":"object"},"success":{"type":"boolean"}},"type":"object"},"contracts.TokenMetrics":{"description":"Token usage metrics (nil for older records)","properties":{"encoding":{"description":"Encoding used (e.g., cl100k_base)","type":"string"},"estimated_cost":{"description":"Optional cost estimate","type":"number"},"input_tokens":{"description":"Tokens in the request","type":"integer"},"model":{"description":"Model used for tokenization","type":"string"},"output_tokens":{"description":"Tokens in the response","type":"integer"},"total_tokens":{"description":"Total tokens (input + output)","type":"integer"},"truncated_tokens":{"description":"Tokens removed by truncation","type":"integer"},"was_truncated":{"description":"Whether response was truncated","type":"boolean"}},"type":"object"},"contracts.Tool":{"properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"approval_status":{"type":"string"},"config_denied":{"description":"ConfigDenied is true when the tool is denied by the server's static\nenabled_tools / disabled_tools config. The user cannot override this toggle.","type":"boolean"},"description":{"type":"string"},"disabled":{"description":"Disabled mirrors ToolApprovalRecord.Disabled so per-tool enable state is\navailable without a second round-trip to the approvals endpoint. Absent\nin the JSON when false (default) to keep responses compact.","type":"boolean"},"held_reason":{"description":"HeldReason, HeldVerdict and HeldSignals mirror the same-named fields on\nstorage.ToolApprovalRecord: the offline-scan evidence that made\ntrust_mode: scan hold this tool for review (spec 086 FR-018). HeldSignals\nnames the matched deterministic check ids, e.g.\n\"tpa.TPA-2026-0001.hidden_instruction\", so a reviewer can see WHY the tool\nis held. All three are omitted for tools that are not held by the scan gate\n(including every record written before the field existed).","type":"string"},"held_signals":{"items":{"type":"string"},"type":"array","uniqueItems":false},"held_verdict":{"type":"string"},"last_used":{"type":"string"},"name":{"type":"string"},"schema":{"type":"object"},"server_name":{"type":"string"},"usage":{"type":"integer"}},"type":"object"},"contracts.ToolAnnotation":{"description":"Tool behavior hints snapshot","properties":{"destructiveHint":{"type":"boolean"},"idempotentHint":{"type":"boolean"},"openWorldHint":{"type":"boolean"},"readOnlyHint":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"contracts.ToolCallRecord":{"description":"The new tool call record","properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"arguments":{"description":"Tool arguments","type":"object"},"config_path":{"description":"Active config file path","type":"string"},"duration":{"description":"Duration in nanoseconds","type":"integer"},"error":{"description":"Error message (failure only)","type":"string"},"execution_type":{"description":"\"direct\" or \"code_execution\"","type":"string"},"id":{"description":"Unique identifier","type":"string"},"mcp_client_name":{"description":"MCP client name from InitializeRequest","type":"string"},"mcp_client_version":{"description":"MCP client version","type":"string"},"mcp_session_id":{"description":"MCP session identifier","type":"string"},"metrics":{"$ref":"#/components/schemas/contracts.TokenMetrics"},"parent_call_id":{"description":"Links nested calls to parent code_execution","type":"string"},"request_id":{"description":"Request correlation ID","type":"string"},"response":{"description":"Tool response (success only)","type":"object"},"server_id":{"description":"Server identity hash","type":"string"},"server_name":{"description":"Human-readable server name","type":"string"},"timestamp":{"description":"When the call was made","type":"string"},"tool_name":{"description":"Tool name (without server prefix)","type":"string"}},"type":"object"},"contracts.UpdateInfo":{"description":"Update information (if available)","properties":{"available":{"description":"Whether an update is available","type":"boolean"},"check_error":{"description":"Error message if update check failed","type":"string"},"checked_at":{"description":"When the update check was performed","type":"string"},"install_channel":{"description":"Detected install channel (homebrew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown) — Spec 079 FR-008","type":"string"},"is_prerelease":{"description":"Whether the latest version is a prerelease","type":"boolean"},"latest_version":{"description":"Latest version available (e.g., \"v1.2.3\")","type":"string"},"nudges_suppressed":{"description":"UI surfaces must stay quiet (CI / non-interactive context); machine-readable fields still report the facts — Spec 079 FR-019","type":"boolean"},"release_url":{"description":"URL to the release page","type":"string"},"update_command":{"description":"One-line update command for the channel; only set when an update is available and the channel has one — Spec 079 FR-009","type":"string"}},"type":"object"},"contracts.UpdatePolicy":{"description":"UpdatePolicy is the effective, hot-reloadable update policy (Spec 092\nFR-015). Always present: the ` + "`" + `update` + "`" + ` object above is omitted both when\nupdate checking is disabled AND when no check has produced a result\nyet, so its absence cannot tell a client whether it is allowed to run\nits own (e.g. Sparkle feed) check. This field states the answer.","properties":{"channel":{"description":"Channel is the tracked release channel: \"stable\" or \"rc\".","type":"string"},"enabled":{"description":"Enabled is the effective automatic-check kill switch: update_check.enabled\nwith MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated\n\"Check for Updates\" stays available regardless.","type":"boolean"},"nudges_suppressed":{"description":"NudgesSuppressed asks UI surfaces to stay quiet (CI / non-interactive)\nwhile machine-readable fields keep reporting the facts.","type":"boolean"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.UsageAggregateResponse":{"properties":{"freshness_ms":{"description":"age of the underlying snapshot in ms","type":"integer"},"generated_at":{"type":"string"},"other":{"$ref":"#/components/schemas/contracts.UsageOtherBucket"},"timeline":{"items":{"$ref":"#/components/schemas/contracts.UsageTimeBucket"},"type":"array","uniqueItems":false},"token_source":{"description":"\"bytes\" (size-based proxy, FR-006)","type":"string"},"tokens_saved":{"description":"echoed from ServerTokenMetrics (FR-007)","type":"integer"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"window":{"type":"string"}},"type":"object"},"contracts.UsageOtherBucket":{"description":"present only when the list was truncated to top-N","properties":{"calls":{"type":"integer"},"tools_folded":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageTimeBucket":{"properties":{"calls":{"type":"integer"},"errors":{"type":"integer"},"start":{"type":"string"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageToolStat":{"properties":{"avg_req_bytes":{"description":"null when no sized request calls","type":"integer"},"avg_resp_bytes":{"description":"null when sized_calls == 0 (only legacy 0-byte calls)","type":"integer"},"blocked":{"type":"integer"},"calls":{"type":"integer"},"error_rate":{"type":"number"},"errors":{"type":"integer"},"last_used":{"type":"string"},"p50_ms":{"type":"integer"},"p95_ms":{"type":"integer"},"rejected":{"description":"spec 093: shed by a concurrency limit; never executed, so excluded from calls/latency","type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"init_timeout":{"description":"InitTimeout is the per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override\n(MCP-3322 / GH #760), serialized as a duration string (e.g. \"120s\"). A nil\npointer means \"leave unchanged\" on PATCH; a present value is applied.\nMirrors config.ServerConfig.InitTimeout's *Duration tri-state.","type":"string"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"max_concurrent_requests":{"description":"MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server\nconcurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is\ntri-state: a nil pointer means \"leave unchanged\" on PATCH and \"inherit\nserver_concurrency_defaults\" on create; an explicit 0 disables that\nsetting for this server; a positive value overrides it. Do NOT collapse\nthem to plain values — an omitted field would then silently reset a\nconfigured limit.","type":"integer"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"type":"boolean"},"trust_mode":{"description":"TrustMode is the per-server trust tier (spec 086): \"auto\", \"scan\", or\n\"manual\". Empty means \"leave unchanged\" on PATCH (and inherit the migrated\ndefault on create). A non-empty value is applied to ServerConfig.TrustMode\nand resolved by EffectiveTrustMode (an unrecognized value fails closed to\nmanual). This is the REST seam for changing the trust tier via\nPOST/PATCH /api/v1/servers.","type":"string"},"url":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.CanonicalConfigPath":{"properties":{"description":{"description":"Brief description","type":"string"},"exists":{"description":"Whether the file exists","type":"boolean"},"format":{"description":"Format identifier (e.g., \"claude_desktop\")","type":"string"},"name":{"description":"Display name (e.g., \"Claude Desktop\")","type":"string"},"os":{"description":"Operating system (darwin, windows, linux)","type":"string"},"path":{"description":"Full path to the config file","type":"string"}},"type":"object"},"httpapi.CanonicalConfigPathsResponse":{"properties":{"os":{"description":"Current operating system","type":"string"},"paths":{"description":"List of canonical config paths","items":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPath"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ConnectConflictResponse":{"properties":{"action":{"description":"already_exists | precondition_failed","type":"string"},"data":{"$ref":"#/components/schemas/connect.ConnectResult"},"error":{"description":"Human-readable message","type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"precondition_token":{"description":"PreconditionToken is the opaque token from the preview this write was\nconfirmed against (Spec 091 FR-005). When present, the core rechecks it\nat write time and responds 409 with action \"precondition_failed\" —\nwriting nothing — if the config or the entry MCPProxy would write has\ndrifted since; the caller then re-previews instead of retrying. Absent\nmeans exactly the pre-091 behavior. A replace-classified flow sends this\nTOGETHER with force=true: the token, not the absence of force, is the\noverwrite safety.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.ImportFromPathRequest":{"properties":{"format":{"description":"Optional format hint","type":"string"},"path":{"description":"File path to import from","type":"string"},"rename":{"additionalProperties":{"type":"string"},"description":"Rename maps a server name → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys are matched against\neither the raw source name (OriginalName) or the sanitized name shown\nin the preview (Server.Name); these differ for names that need\nsanitizing (e.g. \"Figma Desktop\" → \"Figma_Desktop\"). Keys not present\nin the imported set are ignored.","type":"object"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportRequest":{"properties":{"content":{"description":"Raw JSON or TOML content","type":"string"},"format":{"description":"Optional format hint","type":"string"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportResponse":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/configimport.FailedServer"},"type":"array","uniqueItems":false},"format":{"type":"string"},"format_name":{"type":"string"},"imported":{"items":{"$ref":"#/components/schemas/httpapi.ImportedServerResponse"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/configimport.SkippedServer"},"type":"array","uniqueItems":false},"summary":{"$ref":"#/components/schemas/configimport.ImportSummary"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportedServerResponse":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (image,\nnetwork_mode, extra_args, working_dir, enabled). A nil pointer\nmeans \"do not touch isolation config\"; an empty-but-present\nobject on PATCH intentionally clears the overrides.","properties":{"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.OnboardingMarkRequest":{"properties":{"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value. The stored enum is wider (Spec 080\nFR-001): a \"skipped\" request for a previously untouched connect step\nis upgraded server-side to \"completed_external\" when the install\nshows positive evidence of an external connection (Spec 080 FR-002).\n\"completed_external\" is NOT accepted from clients — it must never be\npersisted without that server-verified evidence (edge case: \"never\nguess completed_external without positive evidence\").","type":"string"},"engaged":{"description":"Engaged marks the wizard as engaged (completed or explicitly skipped).\nOnce true, the wizard does not auto-show again.","type":"boolean"},"mark_shown":{"description":"MarkShown records the wizard's first display time if not already set.","type":"boolean"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","type":"string"}},"type":"object"},"httpapi.SetActiveProfileRequest":{"properties":{"active_profile":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.UndoConnectRequest":{"properties":{"backup_name":{"description":"BackupName is the bare filename (filepath.Base) of the backup returned as\nbackup_path by the preceding connect — a name, never a path. Undo resolves\nthe full path server-side by joining it with the client's own config\ndirectory, so a client-supplied value can never contribute a directory\ncomponent (traversal is impossible by construction). Empty means the\nconnect created the file (no prior file existed), so undo removes it.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.UpdateFailureRequest":{"properties":{"stage":{"description":"Stage is the failure stage of the update session.","enum":["appcast","download","install","other"],"type":"string"}},"type":"object"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned upstream servers (MCP-2769). It is OFF by\ndefault and deliberately kept out of the AllowedSystemVars default list:\nproxy URLs frequently carry credentials (http://user:pass@proxy), so\nforwarding them to every stdio upstream is a credential-leak risk. When\nenabled, values are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"},"telemetry.FeedbackContext":{"properties":{"arch":{"type":"string"},"connected_server_count":{"type":"integer"},"edition":{"type":"string"},"os":{"type":"string"},"routing_mode":{"type":"string"},"server_count":{"type":"integer"},"version":{"type":"string"}},"type":"object"},"telemetry.FeedbackRequest":{"properties":{"category":{"description":"bug, feature, other","type":"string"},"context":{"$ref":"#/components/schemas/telemetry.FeedbackContext"},"email":{"type":"string"},"message":{"type":"string"}},"type":"object"},"telemetry.FeedbackResponse":{"properties":{"error":{"type":"string"},"issue_url":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"description":"API key authentication via query parameter. Use ?apikey=your-key","in":"query","name":"apikey","type":"apiKey"}}}, "info": {"contact":{"name":"MCPProxy Support","url":"https://github.com/smart-mcp-proxy/mcpproxy-go"},"description":"{{escape .Description}}","license":{"name":"MIT","url":"https://opensource.org/licenses/MIT"},"title":"{{.Title}}","version":"{{.Version}}"}, "externalDocs": {"description":"","url":""}, - "paths": {"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Omit arguments, response and metadata except a contextual whitelist (intent.reason, intent.operation_type, decision, reason, client_name, client_version) (default: false). For clients that render summary fields only; has_sensitive_data is still derived before metadata is dropped.","in":"query","name":"exclude_payloads","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to export (1-50000, default 10000)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get usage statistics aggregate","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/config":{"get":{"description":"Retrieves the current MCPProxy configuration including all server definitions, global settings, and runtime parameters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetConfigResponse"}}},"description":"Configuration retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Apply configuration","tags":["config"]}},"/api/v1/config/docker-isolation":{"patch":{"description":"Convenience endpoint to flip ` + "`" + `docker_isolation.enabled` + "`" + ` without resending the full config. Persists to disk via the existing config writer — the file watcher then hot-reloads the change. Returns the new state and whether a restart is required for existing connections to pick it up.","requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"}},"type":"object"}}},"description":"New isolation state","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Isolation toggle applied"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Toggle global Docker isolation","tags":["config"]}},"/api/v1/config/validate":{"post":{"description":"Validates a provided MCPProxy configuration without applying it. Checks for syntax errors, invalid server definitions, conflicting settings, and other configuration issues.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to validate","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ValidateConfigResponse"}}},"description":"Configuration validation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Validation failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Validate configuration","tags":["config"]}},"/api/v1/connect":{"get":{"description":"Returns the connection status for all known MCP client applications.\nEach entry indicates whether the client config file exists and whether\nMCPProxy is currently registered in it.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"List of ClientStatus objects"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List client connection status","tags":["connect"]}},"/api/v1/connect/{client}":{"delete":{"description":"Remove the MCPProxy entry from the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional parameters (server_name)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or entry not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disconnect MCPProxy from a client","tags":["connect"]},"get":{"description":"Resolves one client's status by reading its config file on demand.\nThis is the only Connect endpoint that opens a client config file, so\non macOS it is the sole place an App-Data privacy prompt may legitimately\nappear (scoped to this user action). Resolves access_state to\naccessible|absent|denied|malformed and populates remediation when denied.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get a single client's connection status (on-demand)","tags":["connect"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.\nOptionally accepts precondition_token from a preview (Spec 091): when supplied,\nthe core rechecks the raw pre-write state and the entry it would write, and\nrefuses a drifted write with 409 before taking any backup. The 409 body's\naction discriminates the two conflict kinds: \"precondition_failed\" (stale\npreview — re-preview, do not retry) vs \"already_exists\" (entry present — pass\nforce=true). force=true never rescues a stale token.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters (server_name, force, precondition_token)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectConflictResponse"}}},"description":"Conflict: action=already_exists (use force=true) or action=precondition_failed (preview is stale; re-preview)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Connect MCPProxy to a client","tags":["connect"]}},"/api/v1/connect/{client}/preview":{"get":{"description":"Returns the exact entry a subsequent connect would add to the client's\nconfig — target path, server key, entry name, and entry contents — WITHOUT\nmodifying the file or creating a backup (Spec 078 US1). The embedded API key\nis masked in the payload; contains_api_key flags that a credential is written.\nentry_exists distinguishes a create from an overwrite of a same-named entry.\nReads the config on demand to classify create-vs-overwrite, so on macOS this\nmay raise an App-Data privacy prompt; a denial returns 403 + remediation.\nSpec 091 adds three fields: existing_entry_summary (present only when\nentry_exists — a sanitized, non-secret projection of the entry being replaced:\nits name, type, endpoint with query/userinfo stripped, command, and header and\nenv NAMES, never values); precondition_token (always present — an opaque keyed\ndigest of the raw pre-write state and the pending entry, echoed back on POST\nconnect to detect drift); and connect_refusal (present when the write would\nrefuse regardless of intent, e.g. a non-create-capable client with no config —\ntreat its presence as \"Connect unavailable\").","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Entry name to preview (defaults to mcpproxy); mirror the value passed to POST connect","in":"query","name":"server_name","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectPreview"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview the change a connect would make (no write)","tags":["connect"]}},"/api/v1/connect/{client}/undo":{"post":{"description":"Reverts the connect that produced the named backup (Spec 078 US3):\nrestores the client config byte-for-byte from that backup, or — when\nbackup_name is empty because the connect created the file — deletes the\ncreated file. backup_name is the bare filename of the backup the connect\nreturned (never a path); undo resolves the full path server-side inside\nthe client's own config directory, so a client value cannot escape it.\nRefuses with 409 when the config changed since the connect (undo never\nclobbers later edits; use DELETE /connect/{client} for a surgical entry\nremoval instead). Takes its own safety backup first; its path is returned\nas backup_path in the result.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UndoConnectRequest"}}},"description":"Undo parameters (server_name, backup_name = the bare filename of the backup the preceding connect returned)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult (action restored|deleted)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (e.g. backup_name is a path, or not a backup of this client's config)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or backup no longer exists"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Config changed since connect; undo refused"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Undo a connect, restoring the pre-connect config","tags":["connect"]}},"/api/v1/diagnostics":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/docker/status":{"get":{"description":"Retrieve current Docker availability and recovery status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Docker status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get Docker status","tags":["docker"]}},"/api/v1/doctor":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/feedback":{"post":{"description":"Submit a bug report, feature request, or general feedback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackRequest"}}},"description":"Feedback request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Bad Request"},"429":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyAuth":[]}],"summary":"Submit feedback","tags":["feedback"]}},"/api/v1/index/search":{"get":{"description":"Search across all upstream MCP server tools using BM25 keyword search","parameters":[{"description":"Search query","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Maximum number of results","in":"query","name":"limit","schema":{"default":10,"maximum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchToolsResponse"}}},"description":"Search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing query parameter)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search for tools","tags":["tools"]}},"/api/v1/info":{"get":{"description":"Get essential server metadata including version, web UI URL, endpoint addresses, and update availability\nThis endpoint is designed for tray-core communication and version checking\nUse refresh=true query parameter to force an immediate update check against GitHub\nThe launched_by field reports durable launch provenance (\"tray\", \"installer\", or \"\" for user-launched/unknown)","parameters":[{"description":"Force immediate update check against GitHub","in":"query","name":"refresh","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Server information with optional update info"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server information","tags":["status"]}},"/api/v1/onboarding/mark":{"post":{"description":"Updates wizard engagement and per-step status. Once engaged is\ntrue, the wizard does not auto-show again, even if state regresses.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.OnboardingMarkRequest"}}},"description":"Mark request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Mark onboarding wizard state (Spec 046)","tags":["onboarding"]}},"/api/v1/onboarding/state":{"get":{"description":"Returns the wizard engagement record alongside live predicates\n(whether any client is connected, whether any server is configured),\nplus a derived ShouldShowWizard flag the frontend can rely on.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"OnboardingStateResponse"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get onboarding wizard state and predicates (Spec 046)","tags":["onboarding"]}},"/api/v1/profiles":{"get":{"description":"List all configured profiles with their effective servers and indexed tool count (Profiles v2). A profile scopes tool discovery and calls to a named subset of upstream servers.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Profile list"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Configuration unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List configured profiles","tags":["profiles"]}},"/api/v1/profiles/active":{"get":{"description":"Get the server-level default active profile used by UI surfaces (Web UI / tray). Empty string means \"all servers\". Note: within a live MCP session, the set_profile tool selection takes precedence over this default.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the default active profile","tags":["profiles"]},"put":{"description":"Set the server-level default active profile for UI surfaces. The slug must match a configured profile; pass an empty string to clear. This does not affect live MCP sessions, which use the set_profile tool.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.SetActiveProfileRequest"}}},"description":"Profile slug to activate (empty clears)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request body"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Set the default active profile","tags":["profiles"]}},"/api/v1/registries":{"get":{"description":"Retrieves list of all MCP server registries that can be browsed for discovering and installing new upstream servers. Includes registry metadata, server counts, and API endpoints.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetRegistriesResponse"}}},"description":"Registries retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to list registries"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List available MCP server registries","tags":["registries"]},"post":{"description":"Adds a generic modelcontextprotocol/registry v0.1 https endpoint as a custom registry (MCP-866). The source is always tagged custom/unverified, so every server discovered through it lands quarantined and can never skip quarantine.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddRegistrySourceRequest"}}},"description":"Registry source (https url + optional protocol/id/name)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source added"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/contracts.ErrorResponse"},{"$ref":"#/components/schemas/contracts.ErrorResponse"}]}}},"description":"Forbidden (agent tokens cannot mutate registries)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin | duplicate_registry"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a user-supplied registry source","tags":["registries"]}},"/api/v1/registries/{id}":{"delete":{"description":"Removes a custom/unverified registry previously added via add-source (MCP-1057). Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source removed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove a user-added custom registry source","tags":["registries"]},"put":{"description":"Updates a custom registry previously added via add-source (MCP-1072): name, url, servers-url. Empty fields are left unchanged. Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found; a non-https url yields invalid_registry_url. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.EditRegistrySourceRequest"}}},"description":"Fields to update (name/url/servers_url; empty = unchanged)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required | invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Edit a user-added custom registry source","tags":["registries"]}},"/api/v1/registries/{id}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot add servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove an upstream server","tags":["servers"]},"patch":{"description":"Update specific fields of an existing upstream MCP server configuration.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Fields to update (all optional)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server updated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - no fields or invalid body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/config-to-secret":{"post":{"description":"Atomically reads the real value from the server config, stores it in the OS keyring, and rewrites the config field to ` + "`" + `${keyring:\u003cname\u003e}` + "`" + `. Unblocks the UI's Convert-to-secret affordance for values the API redacts on the read path.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored, config updated with reference"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad scope/key/secret_name, or value is already a reference / empty"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server or key not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver or config update failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Convert a header / env value to a keyring secret","tags":["servers"]}},"/api/v1/servers/{id}/disable":{"post":{"description":"Disable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server disabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/discover-tools":{"post":{"description":"Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool discovery triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot discover tools)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to discover tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Discover tools for a specific server","tags":["servers"]}},"/api/v1/servers/{id}/enable":{"post":{"description":"Enable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server enabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/login":{"post":{"description":"Initiate OAuth authentication flow for a specific upstream MCP server. Returns structured OAuth start response with correlation ID for tracking.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthStartResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthFlowError"}}},"description":"OAuth error (client_id required, DCR failed, etc.)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Trigger OAuth login for server","tags":["servers"]}},"/api/v1/servers/{id}/logout":{"post":{"description":"Clear OAuth authentication token and disconnect a specific upstream MCP server. The server will need to re-authenticate before tools can be used again.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"OAuth logout completed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled or read-only mode)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Clear OAuth token and disconnect server","tags":["servers"]}},"/api/v1/servers/{id}/logs":{"get":{"description":"Retrieve log entries for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Number of log lines to retrieve","in":"query","name":"tail","schema":{"default":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerLogsResponse"}}},"description":"Server logs retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server logs","tags":["servers"]}},"/api/v1/servers/{id}/quarantine":{"post":{"description":"Place a specific upstream MCP server in quarantine to prevent tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server quarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Quarantine a server","tags":["servers"]}},"/api/v1/servers/{id}/refresh":{"post":{"description":"Re-discover and re-index a specific upstream MCP server's tools without changing any security state. Alias of discover-tools, named for the upstream_servers 'refresh' operation; use it to make just-approved tools searchable immediately.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool refresh triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot refresh)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Refresh a server's tools","tags":["servers"]}},"/api/v1/servers/{id}/restart":{"post":{"description":"Restart the connection to a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server restarted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/tool-calls":{"get":{"description":"Retrieves tool call history filtered by upstream server ID. Returns recent tool executions for the specified server including timestamps, arguments, results, and errors. Useful for server-specific debugging and monitoring.","parameters":[{"description":"Upstream server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolCallsResponse"}}},"description":"Server tool calls retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get server tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history for specific server","tags":["tool-calls"]}},"/api/v1/servers/{id}/tools":{"get":{"description":"Retrieve all available tools for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolsResponse"}}},"description":"Server tools retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/block":{"post":{"description":"Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The \"blocked\" field counts tools actually blocked.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Block result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Block (approve+disable) tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/disable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/enable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/unquarantine":{"post":{"description":"Remove a specific upstream MCP server from quarantine to allow tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server unquarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Unquarantine a server","tags":["servers"]}},"/api/v1/sessions":{"get":{"description":"Retrieves paginated list of active and recent MCP client sessions. Each session represents a connection from an MCP client to MCPProxy, tracking initialization time, tool calls, and connection status.","parameters":[{"description":"Maximum number of sessions to return (1-100, default 10)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of sessions to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter by session status","in":"query","name":"status","schema":{"enum":["active","closed"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid status filter"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get sessions"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get active MCP sessions","tags":["sessions"]}},"/api/v1/sessions/{id}":{"get":{"description":"Retrieves detailed information about a specific MCP client session including initialization parameters, connection status, tool call count, and activity timestamps.","parameters":[{"description":"Session ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionDetailResponse"}}},"description":"Session details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get MCP session details by ID","tags":["sessions"]}},"/api/v1/stats/tokens":{"get":{"description":"Retrieve token savings statistics across all servers and sessions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Token statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get token savings statistics","tags":["stats"]}},"/api/v1/status":{"get":{"description":"Get comprehensive server status including running state, listen address, upstream statistics, and timestamp","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server status","tags":["status"]}},"/api/v1/telemetry/payload":{"get":{"description":"Render the exact JSON heartbeat payload that mcpproxy would next send to the telemetry endpoint, without making a network call. Counters in the payload reflect the current in-memory state. Spec 042.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Telemetry heartbeat payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/telemetry/update-failure":{"post":{"description":"Records one terminal update-session failure, identified only by its\nstage (appcast, download, install, other). The body carries no error\ntext, URL, or version — the stage is the only value transmitted.\nReturns 204 both when the occurrence was durably persisted and when\ntelemetry is inactive at event time (config opt-out, environment\nopt-out, CI, or dev build), in which case nothing is recorded.\nCallers cannot and need not distinguish the two.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UpdateFailureRequest"}}},"description":"Update failure stage","required":true},"responses":{"204":{"description":"Accepted (recorded, or a deliberate no-op while telemetry is inactive)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Malformed body, unknown field, trailing value, or stage outside the closed set"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Persistence failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Record a desktop auto-update failure occurrence (Spec 095)","tags":["telemetry"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}}, + "paths": {"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Omit arguments, response and metadata except a contextual whitelist (intent.reason, intent.operation_type, decision, reason, client_name, client_version) (default: false). For clients that render summary fields only; has_sensitive_data is still derived before metadata is dropped.","in":"query","name":"exclude_payloads","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to export (1-50000, default 10000)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get usage statistics aggregate","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/code/scripts":{"get":{"description":"List the stored scripts available to the code_execution tool. Scripts are ` + "`" + `\u003cname\u003e.js` + "`" + ` / ` + "`" + `\u003cname\u003e.ts` + "`" + ` files in the ` + "`" + `scripts/` + "`" + ` directory next to the active configuration file. Entries are advisory: ` + "`" + `ok` + "`" + ` scripts are invocable, ` + "`" + `ambiguous` + "`" + ` names have both extensions, and ` + "`" + `invalid` + "`" + ` ones report why (empty, oversized, unreadable, non-regular). Read-only — there is no write surface for stored scripts.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Stored scripts and the directory they were read from"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List stored code-execution scripts","tags":["code"]}},"/api/v1/config":{"get":{"description":"Retrieves the current MCPProxy configuration including all server definitions, global settings, and runtime parameters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetConfigResponse"}}},"description":"Configuration retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Apply configuration","tags":["config"]}},"/api/v1/config/docker-isolation":{"patch":{"description":"Convenience endpoint to flip ` + "`" + `docker_isolation.enabled` + "`" + ` without resending the full config. Persists to disk via the existing config writer — the file watcher then hot-reloads the change. Returns the new state and whether a restart is required for existing connections to pick it up.","requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"}},"type":"object"}}},"description":"New isolation state","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Isolation toggle applied"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Toggle global Docker isolation","tags":["config"]}},"/api/v1/config/validate":{"post":{"description":"Validates a provided MCPProxy configuration without applying it. Checks for syntax errors, invalid server definitions, conflicting settings, and other configuration issues.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to validate","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ValidateConfigResponse"}}},"description":"Configuration validation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Validation failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Validate configuration","tags":["config"]}},"/api/v1/connect":{"get":{"description":"Returns the connection status for all known MCP client applications.\nEach entry indicates whether the client config file exists and whether\nMCPProxy is currently registered in it.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"List of ClientStatus objects"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List client connection status","tags":["connect"]}},"/api/v1/connect/{client}":{"delete":{"description":"Remove the MCPProxy entry from the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional parameters (server_name)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or entry not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disconnect MCPProxy from a client","tags":["connect"]},"get":{"description":"Resolves one client's status by reading its config file on demand.\nThis is the only Connect endpoint that opens a client config file, so\non macOS it is the sole place an App-Data privacy prompt may legitimately\nappear (scoped to this user action). Resolves access_state to\naccessible|absent|denied|malformed and populates remediation when denied.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get a single client's connection status (on-demand)","tags":["connect"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.\nOptionally accepts precondition_token from a preview (Spec 091): when supplied,\nthe core rechecks the raw pre-write state and the entry it would write, and\nrefuses a drifted write with 409 before taking any backup. The 409 body's\naction discriminates the two conflict kinds: \"precondition_failed\" (stale\npreview — re-preview, do not retry) vs \"already_exists\" (entry present — pass\nforce=true). force=true never rescues a stale token.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters (server_name, force, precondition_token)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectConflictResponse"}}},"description":"Conflict: action=already_exists (use force=true) or action=precondition_failed (preview is stale; re-preview)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Connect MCPProxy to a client","tags":["connect"]}},"/api/v1/connect/{client}/preview":{"get":{"description":"Returns the exact entry a subsequent connect would add to the client's\nconfig — target path, server key, entry name, and entry contents — WITHOUT\nmodifying the file or creating a backup (Spec 078 US1). The embedded API key\nis masked in the payload; contains_api_key flags that a credential is written.\nentry_exists distinguishes a create from an overwrite of a same-named entry.\nReads the config on demand to classify create-vs-overwrite, so on macOS this\nmay raise an App-Data privacy prompt; a denial returns 403 + remediation.\nSpec 091 adds three fields: existing_entry_summary (present only when\nentry_exists — a sanitized, non-secret projection of the entry being replaced:\nits name, type, endpoint with query/userinfo stripped, command, and header and\nenv NAMES, never values); precondition_token (always present — an opaque keyed\ndigest of the raw pre-write state and the pending entry, echoed back on POST\nconnect to detect drift); and connect_refusal (present when the write would\nrefuse regardless of intent, e.g. a non-create-capable client with no config —\ntreat its presence as \"Connect unavailable\").","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Entry name to preview (defaults to mcpproxy); mirror the value passed to POST connect","in":"query","name":"server_name","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectPreview"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview the change a connect would make (no write)","tags":["connect"]}},"/api/v1/connect/{client}/undo":{"post":{"description":"Reverts the connect that produced the named backup (Spec 078 US3):\nrestores the client config byte-for-byte from that backup, or — when\nbackup_name is empty because the connect created the file — deletes the\ncreated file. backup_name is the bare filename of the backup the connect\nreturned (never a path); undo resolves the full path server-side inside\nthe client's own config directory, so a client value cannot escape it.\nRefuses with 409 when the config changed since the connect (undo never\nclobbers later edits; use DELETE /connect/{client} for a surgical entry\nremoval instead). Takes its own safety backup first; its path is returned\nas backup_path in the result.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UndoConnectRequest"}}},"description":"Undo parameters (server_name, backup_name = the bare filename of the backup the preceding connect returned)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult (action restored|deleted)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (e.g. backup_name is a path, or not a backup of this client's config)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or backup no longer exists"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Config changed since connect; undo refused"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Undo a connect, restoring the pre-connect config","tags":["connect"]}},"/api/v1/diagnostics":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/docker/status":{"get":{"description":"Retrieve current Docker availability and recovery status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Docker status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get Docker status","tags":["docker"]}},"/api/v1/doctor":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/feedback":{"post":{"description":"Submit a bug report, feature request, or general feedback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackRequest"}}},"description":"Feedback request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Bad Request"},"429":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyAuth":[]}],"summary":"Submit feedback","tags":["feedback"]}},"/api/v1/index/search":{"get":{"description":"Search across all upstream MCP server tools using BM25 keyword search","parameters":[{"description":"Search query","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Maximum number of results","in":"query","name":"limit","schema":{"default":10,"maximum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchToolsResponse"}}},"description":"Search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing query parameter)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search for tools","tags":["tools"]}},"/api/v1/info":{"get":{"description":"Get essential server metadata including version, web UI URL, endpoint addresses, and update availability\nThis endpoint is designed for tray-core communication and version checking\nUse refresh=true query parameter to force an immediate update check against GitHub\nThe launched_by field reports durable launch provenance (\"tray\", \"installer\", or \"\" for user-launched/unknown)","parameters":[{"description":"Force immediate update check against GitHub","in":"query","name":"refresh","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Server information with optional update info"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server information","tags":["status"]}},"/api/v1/onboarding/mark":{"post":{"description":"Updates wizard engagement and per-step status. Once engaged is\ntrue, the wizard does not auto-show again, even if state regresses.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.OnboardingMarkRequest"}}},"description":"Mark request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Mark onboarding wizard state (Spec 046)","tags":["onboarding"]}},"/api/v1/onboarding/state":{"get":{"description":"Returns the wizard engagement record alongside live predicates\n(whether any client is connected, whether any server is configured),\nplus a derived ShouldShowWizard flag the frontend can rely on.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"OnboardingStateResponse"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get onboarding wizard state and predicates (Spec 046)","tags":["onboarding"]}},"/api/v1/profiles":{"get":{"description":"List all configured profiles with their effective servers and indexed tool count (Profiles v2). A profile scopes tool discovery and calls to a named subset of upstream servers.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Profile list"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Configuration unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List configured profiles","tags":["profiles"]}},"/api/v1/profiles/active":{"get":{"description":"Get the server-level default active profile used by UI surfaces (Web UI / tray). Empty string means \"all servers\". Note: within a live MCP session, the set_profile tool selection takes precedence over this default.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the default active profile","tags":["profiles"]},"put":{"description":"Set the server-level default active profile for UI surfaces. The slug must match a configured profile; pass an empty string to clear. This does not affect live MCP sessions, which use the set_profile tool.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.SetActiveProfileRequest"}}},"description":"Profile slug to activate (empty clears)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request body"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Set the default active profile","tags":["profiles"]}},"/api/v1/registries":{"get":{"description":"Retrieves list of all MCP server registries that can be browsed for discovering and installing new upstream servers. Includes registry metadata, server counts, and API endpoints.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetRegistriesResponse"}}},"description":"Registries retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to list registries"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List available MCP server registries","tags":["registries"]},"post":{"description":"Adds a generic modelcontextprotocol/registry v0.1 https endpoint as a custom registry (MCP-866). The source is always tagged custom/unverified, so every server discovered through it lands quarantined and can never skip quarantine.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddRegistrySourceRequest"}}},"description":"Registry source (https url + optional protocol/id/name)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source added"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/contracts.ErrorResponse"},{"$ref":"#/components/schemas/contracts.ErrorResponse"}]}}},"description":"Forbidden (agent tokens cannot mutate registries)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin | duplicate_registry"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a user-supplied registry source","tags":["registries"]}},"/api/v1/registries/{id}":{"delete":{"description":"Removes a custom/unverified registry previously added via add-source (MCP-1057). Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source removed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove a user-added custom registry source","tags":["registries"]},"put":{"description":"Updates a custom registry previously added via add-source (MCP-1072): name, url, servers-url. Empty fields are left unchanged. Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found; a non-https url yields invalid_registry_url. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.EditRegistrySourceRequest"}}},"description":"Fields to update (name/url/servers_url; empty = unchanged)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required | invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Edit a user-added custom registry source","tags":["registries"]}},"/api/v1/registries/{id}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot add servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove an upstream server","tags":["servers"]},"patch":{"description":"Update specific fields of an existing upstream MCP server configuration.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Fields to update (all optional)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server updated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - no fields or invalid body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/config-to-secret":{"post":{"description":"Atomically reads the real value from the server config, stores it in the OS keyring, and rewrites the config field to ` + "`" + `${keyring:\u003cname\u003e}` + "`" + `. Unblocks the UI's Convert-to-secret affordance for values the API redacts on the read path.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored, config updated with reference"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad scope/key/secret_name, or value is already a reference / empty"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server or key not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver or config update failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Convert a header / env value to a keyring secret","tags":["servers"]}},"/api/v1/servers/{id}/disable":{"post":{"description":"Disable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server disabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/discover-tools":{"post":{"description":"Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool discovery triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot discover tools)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to discover tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Discover tools for a specific server","tags":["servers"]}},"/api/v1/servers/{id}/enable":{"post":{"description":"Enable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server enabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/login":{"post":{"description":"Initiate OAuth authentication flow for a specific upstream MCP server. Returns structured OAuth start response with correlation ID for tracking.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthStartResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthFlowError"}}},"description":"OAuth error (client_id required, DCR failed, etc.)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Trigger OAuth login for server","tags":["servers"]}},"/api/v1/servers/{id}/logout":{"post":{"description":"Clear OAuth authentication token and disconnect a specific upstream MCP server. The server will need to re-authenticate before tools can be used again.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"OAuth logout completed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled or read-only mode)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Clear OAuth token and disconnect server","tags":["servers"]}},"/api/v1/servers/{id}/logs":{"get":{"description":"Retrieve log entries for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Number of log lines to retrieve","in":"query","name":"tail","schema":{"default":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerLogsResponse"}}},"description":"Server logs retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server logs","tags":["servers"]}},"/api/v1/servers/{id}/quarantine":{"post":{"description":"Place a specific upstream MCP server in quarantine to prevent tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server quarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Quarantine a server","tags":["servers"]}},"/api/v1/servers/{id}/refresh":{"post":{"description":"Re-discover and re-index a specific upstream MCP server's tools without changing any security state. Alias of discover-tools, named for the upstream_servers 'refresh' operation; use it to make just-approved tools searchable immediately.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool refresh triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot refresh)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Refresh a server's tools","tags":["servers"]}},"/api/v1/servers/{id}/restart":{"post":{"description":"Restart the connection to a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server restarted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/tool-calls":{"get":{"description":"Retrieves tool call history filtered by upstream server ID. Returns recent tool executions for the specified server including timestamps, arguments, results, and errors. Useful for server-specific debugging and monitoring.","parameters":[{"description":"Upstream server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolCallsResponse"}}},"description":"Server tool calls retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get server tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history for specific server","tags":["tool-calls"]}},"/api/v1/servers/{id}/tools":{"get":{"description":"Retrieve all available tools for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolsResponse"}}},"description":"Server tools retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/block":{"post":{"description":"Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The \"blocked\" field counts tools actually blocked.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Block result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Block (approve+disable) tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/disable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/enable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/unquarantine":{"post":{"description":"Remove a specific upstream MCP server from quarantine to allow tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server unquarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Unquarantine a server","tags":["servers"]}},"/api/v1/sessions":{"get":{"description":"Retrieves paginated list of active and recent MCP client sessions. Each session represents a connection from an MCP client to MCPProxy, tracking initialization time, tool calls, and connection status.","parameters":[{"description":"Maximum number of sessions to return (1-100, default 10)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of sessions to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter by session status","in":"query","name":"status","schema":{"enum":["active","closed"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid status filter"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get sessions"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get active MCP sessions","tags":["sessions"]}},"/api/v1/sessions/{id}":{"get":{"description":"Retrieves detailed information about a specific MCP client session including initialization parameters, connection status, tool call count, and activity timestamps.","parameters":[{"description":"Session ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionDetailResponse"}}},"description":"Session details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get MCP session details by ID","tags":["sessions"]}},"/api/v1/stats/tokens":{"get":{"description":"Retrieve token savings statistics across all servers and sessions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Token statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get token savings statistics","tags":["stats"]}},"/api/v1/status":{"get":{"description":"Get comprehensive server status including running state, listen address, upstream statistics, and timestamp","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server status","tags":["status"]}},"/api/v1/telemetry/payload":{"get":{"description":"Render the exact JSON heartbeat payload that mcpproxy would next send to the telemetry endpoint, without making a network call. Counters in the payload reflect the current in-memory state. Spec 042.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Telemetry heartbeat payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/telemetry/update-failure":{"post":{"description":"Records one terminal update-session failure, identified only by its\nstage (appcast, download, install, other). The body carries no error\ntext, URL, or version — the stage is the only value transmitted.\nReturns 204 both when the occurrence was durably persisted and when\ntelemetry is inactive at event time (config opt-out, environment\nopt-out, CI, or dev build), in which case nothing is recorded.\nCallers cannot and need not distinguish the two.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UpdateFailureRequest"}}},"description":"Update failure stage","required":true},"responses":{"204":{"description":"Accepted (recorded, or a deliberate no-op while telemetry is inactive)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Malformed body, unknown field, trailing value, or stage outside the closed set"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Persistence failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Record a desktop auto-update failure occurrence (Spec 095)","tags":["telemetry"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}}, "openapi": "3.1.0" }` diff --git a/oas/swagger.yaml b/oas/swagger.yaml index c1687d54a..d661c2bab 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -3715,6 +3715,33 @@ paths: summary: Get annotation coverage report tags: - annotations + /api/v1/code/scripts: + get: + description: 'List the stored scripts available to the code_execution tool. + Scripts are `.js` / `.ts` files in the `scripts/` directory next + to the active configuration file. Entries are advisory: `ok` scripts are invocable, + `ambiguous` names have both extensions, and `invalid` ones report why (empty, + oversized, unreadable, non-regular). Read-only — there is no write surface + for stored scripts.' + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.SuccessResponse' + description: Stored scripts and the directory they were read from + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Internal server error + security: + - ApiKeyAuth: [] + - ApiKeyQuery: [] + summary: List stored code-execution scripts + tags: + - code /api/v1/config: get: description: Retrieves the current MCPProxy configuration including all server diff --git a/specs/097-stored-scripts/checklists/requirements.md b/specs/097-stored-scripts/checklists/requirements.md new file mode 100644 index 000000000..331d8ece8 --- /dev/null +++ b/specs/097-stored-scripts/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Server-Side Stored Scripts for Code Execution + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-08-14 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Path-confinement and no-write-path are stated as behavioral requirements (what the system must refuse), not implementation choices. +- Name-validation token set and read-at-invocation freshness chosen as defaults; both documented in Assumptions with rationale. diff --git a/specs/097-stored-scripts/contracts/stored-scripts-api.md b/specs/097-stored-scripts/contracts/stored-scripts-api.md new file mode 100644 index 000000000..e1fcefb1a --- /dev/null +++ b/specs/097-stored-scripts/contracts/stored-scripts-api.md @@ -0,0 +1,25 @@ +# Contract: Stored Scripts API (Spec 097) + +## MCP code_execution tool +- New optional string param `script`; `code` no longer schema-required. +- Exactly one of `code` | `script` (handler-enforced): both/neither → tool error explaining the rule. +- `script` value: validated name (never a path). Not found → error listing first 20 ok names alphabetically + total count (this IS the MCP discovery mechanism; registrations stay static, no list_changed). +- All other params (`input`, options, `language`) unchanged; explicit `language` contradicting the extension → error. +- Execution, budgets, scope enforcement, results: identical to inline. + +## REST +- `POST /api/v1/code/exec`: body gains optional `script`; exactly-one-of violation → HTTP 400, envelope `{ok:false, error:{code:"INVALID_REQUEST", message}}` (the endpoint's existing shape); otherwise identical. +- `GET /api/v1/code/scripts` (NEW, read-only, API-key auth): `{success: true, data: {scripts: [{name, paths, status, reason?}], dir}}`. + +## CLI +- `mcpproxy code exec --script ` — mutually exclusive with `--code`/`--file`; BOTH modes send the NAME into the tool args (never content) — the handler is the only execution-time resolver; in standalone mode the in-process handler's authority comes from the shared config-path helper. +- `mcpproxy code scripts list` — daemon GET when running, else local; `-o json|yaml` supported. + +## Authority +scripts dir = directory of the ACTIVE config file, passed into the server at construction on every surface (daemon: runtime config service; CLI standalone in-process server: --config or ~/.mcpproxy/mcp_config.json via a shared helper); config.GetConfigPath(DataDir) only as last-resort fallback when no path was provided. The handler is the only resolver for execution; the CLI resolves only for daemonless `code scripts list`. Never derived from --data-dir. + +## Records +Activity/history keep storing the executed source as `code` (Spec 024 parity) plus additive `script: `. + +## Non-goals (v1) +No write/upload/delete surface; no config field; no dedicated MCP listing tool; no tools/list_changed. diff --git a/specs/097-stored-scripts/data-model.md b/specs/097-stored-scripts/data-model.md new file mode 100644 index 000000000..c711ae9a0 --- /dev/null +++ b/specs/097-stored-scripts/data-model.md @@ -0,0 +1,40 @@ +# Data Model: Stored Scripts (Spec 097) + +## StoredScript (filesystem-backed, never persisted elsewhere) +| Property | Rules | +|----------|-------| +| name | ASCII [A-Za-z0-9_-], 1–64 chars, case-sensitive | +| file | `/.js` or `.ts` (lowercase ext only), regular file | +| size | 1 byte .. 256 KB (empty and oversize invalid) | +| language | derived from extension; explicit contradicting `language` param rejected | + +## ScriptRef +The `script` MCP/REST parameter or `--script` flag: a validated name, never a path. XOR with `code`. + +## ListEntry +| Field | Values | +|-------|--------| +| name | token-valid base name | +| paths | 1 (ok/invalid) or 2 (ambiguous) source paths | +| status | `ok` \| `ambiguous` \| `invalid` | +| reason | present when invalid: empty / oversized / unreadable | + +Only `ok` entries are invocable; FR-004 error lists first 20 `ok` names + total count. + +## Typed resolution errors (package codescripts) +- NotFound{Available (≤20, alphabetical), Total} +- Ambiguous{Paths} +- Invalid{Reason} (empty | oversized | unreadable | non-regular) +- InvalidName (pre-filesystem) + +## Resolution state machine +``` +name ─invalid token─▶ InvalidName [no fs access — SC-003 proof point] +name ─valid─▶ probe .js/.ts via Root.Lstat + none found ─▶ NotFound(+listing) + both found ─▶ Ambiguous + one found, non-regular ─▶ Invalid(non-regular) + one found ─▶ Root.Open → fd Stat size check ─over─▶ Invalid(oversized) + └─▶ single bounded read ─empty─▶ Invalid(empty) + └─▶ (source, derived language) +``` diff --git a/specs/097-stored-scripts/plan.md b/specs/097-stored-scripts/plan.md new file mode 100644 index 000000000..7daff8e8e --- /dev/null +++ b/specs/097-stored-scripts/plan.md @@ -0,0 +1,94 @@ +# Implementation Plan: Server-Side Stored Scripts for Code Execution + +**Branch**: `097-stored-scripts` | **Date**: 2026-08-14 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/097-stored-scripts/spec.md` + +## Summary + +Let the daemon execute named scripts from `/scripts/` via `script: ""` on the code_execution tool (and REST), `--script` on the CLI, with a `code scripts list` CLI verb backed by a read-only REST listing. A new small `internal/codescripts` package owns name validation (the confinement boundary), platform-appropriate no-follow resolution (R1), listing with statuses, and language derivation; the code_execution handler is the only execution-time resolver on every surface. No write path anywhere; sandbox/logging parity with inline code. + +## Technical Context + +**Language/Version**: Go 1.25 +**Primary Dependencies**: stdlib only. **No new dependencies.** +**Storage**: none — filesystem read-only at invocation time +**Testing**: `go test -race` (new package + httpapi + server + cmd), traversal-corpus table tests, CLI child re-exec tests; symlink cases attempted on every platform and skipped only when symlink creation is unprivileged +**Target Platform**: all supported; both editions +**Project Type**: single Go project, existing layout + one new package +**Performance Goals**: SC-001 — request bytes for a 19KB workflow drop >95%; resolution adds one probe+open+bounded-read per invocation (negligible vs script execution) +**Constraints**: confinement by pre-fs name validation (the SC-003 boundary); symlink rejection atomic on Unix (O_NOFOLLOW), best-effort on Windows (Lstat, documented); 256KB bound via LimitReader(max+1) on the open fd; one validated read per invocation; static tool registrations (discovery is error-driven); no write surface +**Scale/Scope**: 1 new package (~4 files), 3 registration-site edits, handler seam, REST endpoint + request field, CLI verb + flag, docs/swagger + +## Constitution Check + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Performance at Scale | PASS | Per-invocation file read; no index/search impact. | +| II. Actor-Based Concurrency | PASS | No new goroutines; pure request-scoped reads. | +| III. Configuration-Driven | PASS | Scripts dir derived from the active config file location; no new config field in v1 (deliberate — spec Assumptions). | +| IV. Security by Default | PASS | Token validation before fs access (the boundary); atomic no-follow open on Unix, checked policy on Windows; no write path; REST inherits API-key auth. | +| V. TDD | PASS | Red-green per task; traversal corpus; parity tests. | +| VI. Documentation Hygiene | PASS | Tool descriptions ×3 sites, docs set, swagger regen. | + +**Post-design re-check**: PASS. + +## Project Structure + +### Documentation (this feature) + +```text +specs/097-stored-scripts/ +├── plan.md, research.md, data-model.md, quickstart.md +├── contracts/stored-scripts-api.md +└── tasks.md (Phase 2) +``` + +### Source Code (repository root) + +```text +internal/codescripts/ # NEW package — single owner of script semantics +├── codescripts.go # ValidateName, Resolve (R1 revised: join + Unix O_NOFOLLOW atomic / +│ # Windows Lstat best-effort; LimitReader(max+1)), List (status model), DeriveLanguage +├── codescripts_test.go # traversal corpus vs ValidateName (pre-fs proof), resolve/list/language tests; +│ # symlink cases ATTEMPTED on every platform, skipped only when symlink +│ # creation is unprivileged (Windows), incl. a reparse-point case there + +internal/server/ +├── mcp_code_execution.go # script XOR code seam (hoisted above :79), scripts-dir authority incl. +│ # mainServer-nil fallback (R2 trap 1), language contradiction check (R7), +│ # activity/history additive "script" key (R6), not-found error w/ 20+count +├── mcp.go # registration: optional script param, code no longer Required (R3) +├── mcp_routing.go # same ×2 incl. the disabled stub (R3): stub gains optional script / +│ # code-not-required so script calls reach the disabled handler, but keeps +│ # ONLY the disabled description (no discovery/executable text) + +internal/httpapi/ +├── code_exec.go # Script field + XOR 400 +├── code_scripts.go # NEW: GET /api/v1/code/scripts (handleListScripts, {success,data}, swagger godoc) +├── server.go # route registration beside :843 + +internal/cliclient/ +├── client.go # CodeExecOptions.Script + +cmd/mcpproxy/ +├── code_cmd.go # --script flag, 3-way exclusion, codeConfigFilePath() helper (R2 trap 2), +│ # resolution ordering fix (R4), code scripts list subcommand + +oas/ # make swagger +docs/… # code-execution docs set +``` + +**Structure Decision**: one new leaf package (`internal/codescripts`) with zero internal deps so cmd/, httpapi/, and server/ can all import it without cycles; it is the single place the confinement idiom exists. + +## Design Outline + +1. **codescripts package**: `ValidateName(name) error` (token rules, no fs — the SC-003 boundary); `Resolve(scriptsDir, name, explicitLanguage) (source []byte, language string, err error)` — both-extension probe (Lstat), ambiguity check, then the platform open idiom (R1 revised: Unix `os.OpenFile(path, O_RDONLY|O_NOFOLLOW)` atomic; Windows Lstat→Open→fstat best-effort), `io.LimitReader(256KB+1)` rejecting the extra byte; typed errors (NotFound{Available ≤20, Total}, Ambiguous, Invalid{Reason}, InvalidName); `List(scriptsDir) []Entry{Name, Paths, Status, Reason}`. +2. **Authority is explicit, single-owner**: the ACTIVE config file path is passed into MCPProxyServer at construction (daemon: runtime config service; CLI standalone in-process server: `codeConfigFilePath()` — new shared helper honoring `--config`); the handler derives `scripts/` from it, with `config.GetConfigPath(cfg.DataDir)` only as the documented last-resort fallback when no path was provided. The CLI NEVER resolves scripts for execution — in both daemon and standalone modes the NAME goes into the tool args and the handler resolves, so XOR, recording, and errors live in exactly one place. Only `code scripts list` without a daemon lists locally via codescripts. +3. **Tool seam**: parse `script` before the code requirement; XOR error; resolve → source + derived language; contradiction check (CLI passes `language` only when the flag was explicitly set — `Flags().Changed("language")` — so the flag default cannot fake an explicit contradiction); downstream identical to inline (options, budgets, records + `script` key). +4. **REST**: Script field; exactly-one-of violation → HTTP 400 with the endpoint's existing bespoke envelope `{ok:false, error:{code:"INVALID_REQUEST", message:...}}` (contract pinned); listing endpoint with the standard {success,data} envelope; swagger godoc + regen. +5. **CLI**: `--script` (exclusive with `--code`/`--file`), both modes send the name via CodeExecOptions.Script / tool args; `code scripts list` → daemon GET when running else local List; `-o json|yaml` via existing formatter. +6. **Descriptions**: shared `codeExecutionScriptDescription` constant; all three registration sites; document error-driven discovery. + +## Complexity Tracking + +Not needed — no violations. diff --git a/specs/097-stored-scripts/quickstart.md b/specs/097-stored-scripts/quickstart.md new file mode 100644 index 000000000..46aae4ef7 --- /dev/null +++ b/specs/097-stored-scripts/quickstart.md @@ -0,0 +1,19 @@ +# Quickstart: stored scripts + +```bash +mkdir -p ~/.mcpproxy/scripts +cat > ~/.mcpproxy/scripts/fetch-prs.js <<'JS' +var rs = call_tools([1,2,3].map(function(n){ + return {server:"github", tool:"get_pull_request", + args:{owner:input.owner, repo:input.repo, pullNumber:n}}; +})); +({titles: rs.map(function(r){ return r.ok ? JSON.parse(r.result.content[0].text).title : "ERR"; })}) +JS + +mcpproxy code scripts list +mcpproxy code exec --script fetch-prs --input '{"owner":"acme","repo":"api"}' +``` + +MCP clients call code_execution with `{"script": "fetch-prs", "input": {...}}` instead of `code` — +the 19KB workflow costs a name per run. Unknown name? The error lists what exists. +Edit by atomic replace (write temp + rename) and the next run picks it up — no restart. diff --git a/specs/097-stored-scripts/research.md b/specs/097-stored-scripts/research.md new file mode 100644 index 000000000..af4bb6519 --- /dev/null +++ b/specs/097-stored-scripts/research.md @@ -0,0 +1,47 @@ +# Research: Server-Side Stored Scripts (Spec 097) + +Verified on branch `097-stored-scripts` (stacked on 096). File:line references to that state. + +## R1. Confined open — os.Root, with a symlink correction + +**Decision (revised after plan review)**: confinement comes from name validation itself — a token-valid name (no separators, no dots) joined to the scripts dir cannot traverse, so os.Root is unnecessary AND insufficient (it follows in-root symlinks and silently defeats caller O_NOFOLLOW via transparent ELOOP re-resolution). Resolution: `filepath.Join(dir, name+ext)` → on Unix `os.OpenFile(path, O_RDONLY|O_NOFOLLOW)` — ATOMIC final-component symlink rejection (ELOOP) with no check-then-open window; on Windows (no O_NOFOLLOW) `os.Lstat` reject non-regular → `os.Open` → `f.Stat()` re-verify regular — best-effort, documented (symlink creation on Windows requires elevation). Size: read via `io.LimitReader(f, 256KB+1)` and reject when the extra byte appears (a Stat-then-read can execute truncated content if the file grows concurrently). + +**Rationale**: go.mod declares go 1.25.5. The security boundary (SC-003) is the pre-filesystem name validator: with no separators or dots in a valid name, the joined path is inside the scripts dir by construction. The symlink-rejection POLICY is then made atomic where the platform allows (Unix O_NOFOLLOW); the earlier Lstat→Root.Open idiom had a race in which a file swapped for an in-root symlink between the two calls would be followed — rejected by plan review. `Root.ReadFile` rejected (no type check, no size bound); os.Root itself dropped (adds nothing over validation, and its symlink-following defeats the policy). + +## R2. Active-config-file authority — three resolvers, two traps + +- Daemon: `server.go:2827 GetConfigPath()` → runtime configSvc path, falls back to `config.GetConfigPath(DataDir)` (`loader.go:426`, defaults to `~/.mcpproxy/mcp_config.json`) — never empty in daemon mode. Scripts dir = `filepath.Join(filepath.Dir(path), "scripts")`. +- **Trap 1**: `mcp_code_execution.go:132-135` — configPath is read only when `p.mainServer != nil`; CLI in-process mode yields `""`. The scripts-dir helper must fall back to `config.GetConfigPath(cfg.DataDir)` when mainServer is nil, and script resolution must be hoisted above the current code-requirement at `:79`. +- **Trap 2**: CLI standalone `loadCodeConfig()` (`code_cmd.go:415-446`) computes the config file path as a local and never returns it; extract a shared `codeConfigFilePath()` helper (honors `--config`, defaults `~/.mcpproxy/mcp_config.json`). Do NOT derive from DataDir (the `--data-dir` override at :443 mutates after path resolution and would disagree with FR-001). + +## R3. Arg seam — one handler, THREE registration sites + +- Handler: `mcp_code_execution.go:79` RequireString("code") → becomes optional + XOR with `script` (args map already at :84). +- Registrations to edit: `mcp.go:912-933`, `mcp_routing.go:506-529`, **and the disabled stub `mcp_routing.go:487-496`** (has its own inline description). `mcp.Required()` must drop from `code` in the live sites (schema can't express XOR; handler enforces). New shared `codeExecutionScriptDescription` constant beside the 096 description constants (`mcp_code_execution.go:28-66`). +- REST: `CodeExecRequest` gains `Script string` (`code_exec.go:23-28`); XOR pre-validated at REST for a proper 400 (file convention, comment at :98-101); forwarding is the plain args map at :150-154. + +## R4. CLI — ordering inversion and the options seam + +- Flags at `code_cmd.go:82-91`; three-way mutual exclusion (`--code`/`--file`/`--script`) in `runCodeExec` :121-129. +- **Ordering trap**: `loadCodeAndInput()` runs at :131 but `loadCodeConfig()` at :143 — script resolution needs the config path; daemon mode sends the NAME (never content) via `cliclient.CodeExecOptions` (`client.go:227-229`, currently only `Language`) — add `Script string`, body set beside language at :253-255; positional signatures untouched. Ping-failure fallback (:189-206) re-resolves standalone — must work there too. +- `code scripts list`: new `codeScriptsCmd` with `list` child, `codeCmd.AddCommand` beside :79. Daemon running → GET /api/v1/code/scripts; else local resolution. + +## R5. REST listing — zero interface change + +Register `r.Get("/code/scripts", s.handleListScripts)` beside `server.go:843`; inherits auth/timeout/telemetry from the /api/v1 block (:690-696); no long-running budget entry needed. **`GetConfigPath()` is already on ServerController (`server.go:139`)** — compute the dir from it; do NOT add an interface method (five hand-written mocks would need updating). Envelope: `s.writeSuccess` / `contracts.NewSuccessResponse` ({success,data} — not code_exec's bespoke {ok,...}). Swagger godoc per `handleGetDockerStatus` pattern (`server.go:5161-5170`) + `make swagger` (CI enforces swagger-verify). + +## R6. Activity/history — purely additive + +Both payloads are free-form maps: `codeExecRecord.Arguments` (`mcp_code_execution.go:348-352`) and `codeExecArgs` (:404-408) get `"script": name` added; `code` keeps the resolved source (Spec 024 parity). Token accounting unaffected (keys off result). + +## R7. TypeScript — flows unchanged; contradiction check server-side + +Setting `options.Language = "typescript"` suffices (transpile at `runtime.go:178-184`). `ValidateLanguage` accepts "" so it cannot detect contradictions — the extension-vs-explicit-language check lives where `effectiveLanguage` is computed (`mcp_code_execution.go:201-203`), after script resolution; deriving effectiveLanguage from the extension also keeps activity records honest. + +## R8. Tests landscape + +- REST: `internal/httpapi/code_exec_test.go` mockController + `TestCodeExecHandler_MissingCode` (XOR template), `TestCodeExecHandler_InvalidLanguage` (contradiction 400). +- Args forwarding: `codeExecArgsCapture` at `internal/server/code_execution_options_test.go:106-112` — assert daemon mode sends `script`, not content. +- CLI: `code_cmd_test.go` child re-exec pattern (:108,:144) incl. ping-failure fallback (:129). +- E2E: `/code/exec` covered in Go e2e tests; **`scripts/test-api-e2e.sh` has no code-exec coverage** — extending it is optional, note honestly. +- SC-003 proof: split the name validator so it is independently callable; table-test the traversal corpus against it (no fs hook needed). **Symlink test cases are attempted on every platform** and skipped at runtime only when symlink creation fails for privilege reasons (Windows non-elevated); include a Windows reparse-point case. Never build-tag them away (repo precedent for silently-vanishing tests). diff --git a/specs/097-stored-scripts/spec.md b/specs/097-stored-scripts/spec.md new file mode 100644 index 000000000..ad9366d40 --- /dev/null +++ b/specs/097-stored-scripts/spec.md @@ -0,0 +1,154 @@ +# Feature Specification: Server-Side Stored Scripts for Code Execution + +**Feature Branch**: `097-stored-scripts` +**Created**: 2026-08-14 +**Status**: Draft +**Input**: User description: "Server-side stored scripts for code execution so long workflows need not be re-sent inline on every call (GitHub issue #986)" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Invoke a stored workflow by name (Priority: P1) + +An AI agent repeatedly runs the same orchestration workflow through the code-execution tool. Today every invocation ships the entire script inline (~4.8k tokens for a 19KB workflow), paid again on each run, retry, parameter tweak, and loop iteration. With stored scripts, the operator drops the workflow into a `scripts/` directory under the mcpproxy config directory once; the agent then invokes it with `script: ""` plus its `input` — the per-run cost falls from the whole script to a name plus its parameters. + +**Why this priority**: This is the entire value of the feature — without invocation-by-reference there is nothing else to build on. + +**Independent Test**: Place a script file in the scripts directory, call the code-execution tool with `script` instead of `code`, and observe the identical result the inline equivalent produces. + +**Acceptance Scenarios**: + +1. **Given** a file `scripts/fetch-prs.js` exists under the config directory, **When** the code-execution tool is called with `script: "fetch-prs"` and an `input` object, **Then** the script executes with that input under exactly the same sandbox limits and returns the same result shape as if its contents had been passed inline as `code`. +2. **Given** both `code` and `script` are supplied in one call, **When** the tool is invoked, **Then** the call is rejected with an error explaining exactly one of the two must be provided. +3. **Given** `script` names a script that does not exist, **When** the tool is invoked, **Then** the call fails with an error listing the available script names (or stating none exist). + +--- + +### User Story 2 - Same invocation from the CLI (Priority: P2) + +An operator or script author runs `mcpproxy code exec --script fetch-prs --input '{"repo":"acme/api"}'` and the daemon executes the stored script — no file path juggling, no inlining, and the invocation works identically in daemon mode. + +**Why this priority**: The CLI is the operator's test loop for authoring scripts; without it, validating a stored script means hand-crafting MCP calls. + +**Independent Test**: With a script stored, run the CLI command and compare output to the equivalent `--code` invocation. + +**Acceptance Scenarios**: + +1. **Given** a stored script and a running daemon, **When** `mcpproxy code exec --script --input '{...}'` runs, **Then** it produces the same result as the inline equivalent, and `--script` combined with `--code` or `--file` is rejected. +2. **Given** no daemon is running, **When** `mcpproxy code exec --script ` runs in standalone mode, **Then** the script is resolved from the same scripts directory and executed locally with identical semantics. + +--- + +### User Story 3 - Discover what scripts exist (Priority: P2) + +An agent (or operator) needs to know which workflows are available. The CLI offers `mcpproxy code scripts list` showing each script's name and source file (asking the daemon when one is running, so both always agree). MCP clients learn the valid names from the code-execution tool itself: the tool description documents the mechanism, and any invocation naming a nonexistent script returns the available names (bounded) in its error — so an agent recovers the name set in one failed call without out-of-band knowledge. Live enumeration surfaces (a dedicated listing tool, `tools/list_changed` notifications) are deliberately out of scope for v1: tool registrations are static, and error-driven discovery covers the recovery path. + +**Why this priority**: Invocation-by-reference is unusable if callers cannot learn the valid names; ranks with the CLI story but below the core invocation. + +**Independent Test**: Store two scripts, list them via CLI and observe both names; verify an MCP client can discover the same names. + +**Acceptance Scenarios**: + +1. **Given** two files in the scripts directory, **When** `mcpproxy code scripts list` runs, **Then** both names appear with their file paths (and `-o json` emits a machine-readable list). +2. **Given** an MCP client connected to the daemon, **When** it invokes the code-execution tool with a `script` name that does not exist, **Then** the error lists the first 20 available script names alphabetically plus the total count when more exist, reflecting the directory's current contents. +3. **Given** an empty or absent scripts directory, **When** listing, **Then** the result is an explicit empty list, not an error. + +--- + +### User Story 4 - Edit scripts without restarting (Priority: P3) + +A script author edits `scripts/fetch-prs.js` while the daemon runs. The next invocation of `script: "fetch-prs"` executes the updated content; adding or deleting a script file likewise takes effect without a daemon restart. + +**Why this priority**: Authoring convenience — the feature works without it, but restart-per-edit would make script development painful. + +**Independent Test**: Invoke a stored script, edit the file, invoke again, observe the new behavior; add and remove files and observe the list change. + +**Acceptance Scenarios**: + +1. **Given** a stored script has been invoked, **When** its file is atomically replaced (write-then-rename) with new content, **Then** the next invocation runs the new content without any daemon restart. +2. **Given** a new file appears in (or is removed from) the scripts directory, **When** scripts are next listed or invoked, **Then** the addition/removal is reflected. + +--- + +### Edge Cases + +- **Name resolution is strictly confined, at open time**: a `script` value is validated as a restricted token (ASCII letters, digits, hyphen, underscore; 1–64 chars; case-sensitive) BEFORE any filesystem access — anything else (path separators, `..`, absolute paths, dots, Unicode) is rejected as an invalid name without touching the filesystem. Resolution then opens `.js` / `.ts` (lowercase extensions only) through a mechanism that confines the open to the scripts directory at open time — a symlink in place of the script file is rejected (scripts must be regular files) — atomically where the platform supports it, best-effort otherwise (on Windows, where creating symlinks requires elevation, the rejection is a checked policy rather than an atomic guarantee). No race can escape the directory in any case, because a validated name cannot traverse. The scripts directory itself may be a symlink (it is operator-controlled). +- **Ambiguous name** (both `.js` and `.ts` exist): the call is rejected with an error naming both candidates; ambiguity is never resolved silently. Listings show the name flagged as ambiguous. +- **One invocation, one read**: the script's bytes are opened and read exactly once per invocation, and all validation (empty, size) applies to exactly the bytes that read returned; a given execution never re-reads the file. Concurrent IN-PLACE writes during that read yield unspecified (but validated) content — safe editing is by atomic replacement (write-then-rename), for which the freshness guarantee (FR-009) fully holds. +- **Unreadable or oversized file**: a script file that cannot be read, exceeds the stored-script size bound (256 KB), or is empty fails the invocation with a specific error; it does not crash or hang the daemon. (Inline `code` today has no explicit bound; the stored-script bound is new and applies only to stored scripts.) +- **Missing scripts directory**: treated as "no scripts" — listing returns empty, invocation returns the not-found error; the daemon does not create the directory on its own in v1. +- **Language selection**: `.ts` scripts run through the same TypeScript path as inline `language: "typescript"`; `.js` scripts run as JavaScript. Supplying an explicit `language` that contradicts the extension is rejected; supplying a matching one is accepted. +- **Listing hygiene**: files whose base name fails the token rules, with unrecognized or uppercase extensions, are ignored by listing and unreachable by invocation (they are not scripts). +- **Sandbox parity**: a stored script executes with exactly the sandbox limits, scope/permission enforcement, timeout/budget options, and activity logging of the equivalent inline invocation — `script` changes only where the source text comes from, nothing about how it runs. +- **No write path**: nothing in v1 creates, modifies, or deletes script files — no MCP tool, no REST endpoint, no CLI verb. The filesystem is the only authoring interface. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST resolve stored scripts from the `scripts/` directory next to the ACTIVE configuration file — the daemon uses the config file it loaded; the CLI in standalone mode uses the config file it resolves by the same rules; the CLI in daemon mode delegates resolution entirely to the daemon (the name, never the content, crosses the wire). Files with supported lowercase extensions (`.js`, `.ts`) and token-valid base names constitute the script set, addressed by base name. +- **FR-002**: The code-execution tool MUST accept `script: ""` as an alternative to `code`, executing the named file's content with all other parameters (`input`, options) behaving identically to an inline invocation. Supplying both `code` and `script`, or neither, MUST be rejected with an explanatory error. +- **FR-003**: Script names MUST be validated (ASCII letters, digits, hyphen, underscore; 1–64 chars) before any filesystem access, and resolution MUST be confined to the scripts directory at open time: the open itself cannot traverse outside the directory, and a non-regular file (symlink, directory, device) at the script path is rejected. No check-then-open window may permit an escape. +- **FR-004**: An invocation naming a nonexistent script MUST fail with an error that includes the first 20 available script names (alphabetical) plus the total count when more exist, or states that none exist — this error is the MCP discovery mechanism (full enumeration beyond 20 is via the CLI/REST listing). +- **FR-005**: A stored-script invocation MUST execute under exactly the same sandbox restrictions, scope/permission enforcement, execution options, budgets, and activity/history logging as the equivalent inline invocation. Records keep storing the executed source exactly as they do for inline code (Spec 024 parity) and additionally carry the script name. +- **FR-006**: The CLI MUST support `mcpproxy code exec --script ` in both daemon and standalone modes, mutually exclusive with `--code` and `--file`, resolving from the same scripts directory with identical semantics. +- **FR-007**: The CLI MUST provide `mcpproxy code scripts list` showing every token-valid script name with its source path(s) and a status — `ok`, `ambiguous` (both candidate paths shown), or `invalid` (empty/oversized/unreadable, with the reason) — honoring the standard output-format flags (`-o json|yaml`); only `ok` scripts are invocable; an empty or absent directory yields an empty list, not an error. +- **FR-008**: MCP clients MUST be able to recover the currently available script names through the code-execution tool surface without out-of-band knowledge, via the FR-004 error listing; the tool description MUST document the `script` parameter and this discovery mechanism. Tool registrations remain static; no `tools/list_changed` notifications or dedicated listing tool in v1. +- **FR-009**: Script content MUST be read at invocation time such that atomic replacements (write-then-rename), additions, and deletions take effect on the next use without a daemon restart; the behavior of an invocation concurrent with an in-place write is unspecified content, validated as read. +- **FR-010**: A script file exceeding 256 KB, unreadable, ambiguous (both extensions present), or empty MUST fail the invocation with a specific error; no partial execution. Each invocation executes exactly one validated read result of the file. +- **FR-011**: v1 MUST NOT expose any write/upload/delete capability for scripts through any API surface; the filesystem is the sole authoring interface. +- **FR-012**: The REST code-execution endpoint MUST accept `script` as an alternative to `code` with the same exactly-one-of validation (HTTP 400 otherwise), and a read-only REST listing endpoint MUST expose the same name/path data as the CLI listing (this is what daemon-mode CLI uses). Both editions; every surface where the code-execution tool is available today. No write/upload/delete surface anywhere (see FR-011). + +### Key Entities + +- **Stored script**: a named, file-backed unit of executable workflow text — identified by base name, sourced from the scripts directory, with a supported-extension file as its content. +- **Script reference**: the `script` parameter value (or `--script` flag) — a validated name, never a path. +- **Script listing**: the enumerable set of token-valid stored scripts — each entry carrying name, source path(s), and status (`ok` | `ambiguous` | `invalid` with reason); the FR-004 error and "available" phrasing refer to `ok` entries only. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Invoking a 19KB stored workflow by name transmits over 95% fewer request bytes than the inline equivalent. +- **SC-002**: For deterministic scripts against stub upstreams, a stored-script invocation and the same source passed inline produce identical results in every tested scenario. +- **SC-003**: Every entry in a traversal corpus (relative traversal, absolute paths, separator injection, dot names, Unicode, symlinked script file, oversized name) is rejected with the invalid-name or non-regular-file error class; corpus entries that are invalid tokens are proven (via test instrumentation of the resolver) to be rejected before any filesystem call. +- **SC-004**: An atomic replacement of a script file is reflected in the very next invocation, with no daemon restart, in every trial of the freshness test. +- **SC-005**: Existing inline `code` behavior is unchanged: the full existing test suite passes without modification (beyond additions). + +## Assumptions + +- The scripts directory location is fixed (`/scripts/`) in v1; a `code_scripts` name→path config map from the issue is deferred — the directory convention alone covers the stated need without new config surface, and a map can be added compatibly later. +- Discovery for MCP clients is error-driven (FR-004/FR-008): tool registrations are built once from static descriptions in this codebase, so live name enumeration in the tool description would go stale; the not-found error is always current. A dedicated listing tool and change notifications are deferred. +- Inline `code` has no explicit size bound today; stored scripts get a fixed 256 KB bound (not configurable in v1) purely to bound daemon-side file reads. +- Hot-freshness is defined by read-at-invocation semantics (one open+read per invocation); no watcher, no cache, hence nothing to invalidate. +- TypeScript stored scripts are supported exactly insofar as inline TypeScript is supported today (same transpilation path). + +## Commit Message Conventions *(mandatory)* + +When committing changes for this feature, follow these guidelines: + +### Issue References +- ✅ **Use**: `Related #986` - Links the commit to the issue without auto-closing +- ❌ **Do NOT use**: `Fixes #986`, `Closes #986`, `Resolves #986` - These auto-close issues on merge + +**Rationale**: Issues should only be closed manually after verification and testing in production, not automatically on merge. + +### Co-Authorship +- ❌ **Do NOT include**: `Co-Authored-By: Claude ` +- ❌ **Do NOT include**: "🤖 Generated with [Claude Code](https://claude.com/claude-code)" + +**Rationale**: Commit authorship should reflect the human contributors, not the AI tools used. + +### Example Commit Message +``` +feat: server-side stored scripts for code execution + +Related #986 + +[Detailed description] + +## Changes +- [Bulleted list] + +## Testing +- [Summary] +``` diff --git a/specs/097-stored-scripts/tasks.md b/specs/097-stored-scripts/tasks.md new file mode 100644 index 000000000..4844ba60e --- /dev/null +++ b/specs/097-stored-scripts/tasks.md @@ -0,0 +1,49 @@ +# Tasks: Server-Side Stored Scripts for Code Execution + +**Input**: Design documents from `/specs/097-stored-scripts/` +**Prerequisites**: plan.md, research.md, data-model.md, contracts/stored-scripts-api.md, quickstart.md +**Convention**: TDD per constitution — tests first, observed failing. + +## Phase 1: Setup + +No setup tasks — existing project, no new dependencies. + +## Phase 2: Foundational + +- [x] T001 Create internal/codescripts package: ValidateName (token rules, no fs — the SC-003 boundary), Resolve per plan step 1 (both-extension Lstat probe, ambiguity, Unix O_NOFOLLOW atomic open / Windows Lstat best-effort split via _unix/_windows files or runtime.GOOS, LimitReader(256KB+1) rejecting the extra byte, one read), List (Entry{Name, Paths, Status ok|ambiguous|invalid, Reason}), DeriveLanguage (.ts→typescript, .js→javascript, explicit-contradiction error); typed errors NotFound{Available ≤20 alphabetical, Total}, Ambiguous{Paths}, Invalid{Reason}, InvalidName. TDD in internal/codescripts/codescripts_test.go: traversal corpus (relative traversal, absolute, separators, dots, Unicode, >64 chars, empty) proving InvalidName BEFORE any fs call (validator is pure — table test), resolve/list/ambiguity/empty/oversize/unreadable cases, freshness (atomic rename picked up next Resolve), symlink cases attempted on every platform and skipped only on symlink-creation privilege failure (Windows reparse-point case included). +- [x] T002 Explicit config-path authority: add configFilePath to MCPProxyServer construction (daemon: from runtime config service via existing GetConfigPath plumbing; CLI in-process: new shared codeConfigFilePath() helper in cmd/mcpproxy/code_cmd.go honoring --config, defaulting ~/.mcpproxy/mcp_config.json); scripts dir = Dir(configFilePath)/scripts with config.GetConfigPath(cfg.DataDir) as documented last-resort fallback. Failing-first tests: helper unit test (default + --config override); server-side test that the handler sees the constructed path. + +## Phase 3: US1 — Invoke a stored workflow by name (P1) + +- [x] T003 [US1] Failing tests in internal/server (code_execution_options_test.go or new file): script XOR code (both→error, neither→error, exact messages), script resolves from a t.TempDir scripts dir and executes identically to inline (same stub ToolCaller, same result), not-found error lists first 20 ok names + total count, language contradiction rejected, .ts script transpiles (language derived), activity/history args carry script name AND resolved source as code. +- [x] T004 [US1] Implement the handler seam in internal/server/mcp_code_execution.go: hoist script/code parse above the current RequireString, resolve via codescripts using the T002 authority, contradiction check at the effectiveLanguage site, additive "script" key in codeExecRecord.Arguments and codeExecArgs. Make T003 green. +- [x] T005 [US1] Registrations: shared codeExecutionScriptDescription constant in mcp_code_execution.go; optional script param + code no-longer-Required in internal/server/mcp.go and mcp_routing.go live site; disabled stub gains optional script/code-not-required but keeps only the disabled description; failing-first surface test asserting all three schemas. + +## Phase 4: US2 — CLI invocation (P2) + +- [x] T006 [US2] Failing tests: cmd/mcpproxy/code_cmd_test.go child re-exec — --script over daemon mode (httptest server asserts request body carries script and NOT content), 3-way mutual-exclusion rejections (--script+--code, --script+--file), --language only sent when Flags().Changed; internal/cliclient test for CodeExecOptions.Script body field. +- [x] T007 [US2] Implement: --script flag + exclusion in runCodeExec, CodeExecOptions.Script in internal/cliclient/client.go, standalone mode passes the name through the in-process handler (no CLI-side resolution), ping-failure fallback passes the same name. Make T006 green. + +## Phase 5: US3 — Discovery (P2) + +- [x] T008 [US3] Failing tests: internal/httpapi/code_scripts_test.go — GET /api/v1/code/scripts returns {success,data:{scripts:[{name,paths,status,reason?}],dir}} incl. ambiguous+invalid entries, auth inherited (401 without key), empty-dir empty list; REST POST /code/exec XOR → 400 {ok:false,error:{code:"INVALID_REQUEST"}} in internal/httpapi/code_exec_test.go. +- [x] T009 [US3] Implement internal/httpapi/code_scripts.go (handleListScripts using s.controller.GetConfigPath(), swagger godoc per handleGetDockerStatus pattern) + route beside /code/exec; Script field + XOR 400 in code_exec.go. Make T008 green. +- [x] T010 [US3] CLI listing: `mcpproxy code scripts list` (cobra codeScriptsCmd + list child) — daemon GET when running else local codescripts.List, -o json|yaml via existing formatter; failing-first child re-exec test for the daemon path and a direct test for the local path. + +## Phase 6: US4 — Freshness (P3) + +- [x] T011 [US4] Failing test at the handler level: invoke stored script, atomically replace the file (write temp + rename), invoke again → new content executes; add/remove file reflected in next listing. (Package-level freshness already pinned in T001; this pins the end-to-end path.) Implementation should already satisfy — fix if not; no test weakening. + +## Phase 7: Polish + +- [x] T012 [P] `make swagger` regen (new endpoint + request field); verify swagger-verify clean. (`GET /api/v1/code/scripts` is now in `oas/swagger.yaml` + `oas/docs.go`; regen is idempotent, so `swagger-verify` passes once the artifacts are committed. The `script` request field does NOT appear because `POST /api/v1/code/exec` has never carried swagger annotations — pre-existing gap, `CodeExecRequest` is unreferenced by any `@Router`/`@Param`; annotating that handler is out of this task's oas-only scope.) +- [x] T013 [P] Docs: stored-scripts section (authoring, naming rules, atomic-replace freshness, discovery, 256KB bound, no-write-path) in docs/features/code-execution.md, docs/code_execution/{overview,api-reference,cookbook,troubleshooting}.md, docs/configuration.md pointer; quickstart example from specs/097-stored-scripts/quickstart.md. +- [ ] T014 Full verification: both edition builds; go test -race -count=1 ./internal/... ; server-edition tags; lint v2 (.github/.golangci.yml); ./scripts/test-api-e2e.sh (revert e2e-config churn); optional smoke: REST exec of a stored script against the e2e instance. + +## Dependencies + +T001→T002 (package before authority wiring) → US1 (T003–T005) → US2/US3 in parallel (different files: cmd+cliclient vs httpapi) → US4 → Polish (T012/T013 parallel; T014 last). + +## Implementation Strategy + +MVP = T001–T005 (stored scripts invocable over MCP). Single engine agent for T001–T005; a second agent for T006–T011 (CLI+REST+freshness) after; polish agents parallel. Orchestrator runs T014.