From b9ab2af71a0199b4b96b07f23d67fdd21a74d9a9 Mon Sep 17 00:00:00 2001
From: Algis Dumbris
Date: Thu, 2 Jul 2026 17:12:00 +0300
Subject: [PATCH 1/7] feat(connect): preview endpoint sharing buildServerEntry
with the write path
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
First slice of specs/078-connect-trust-preview US1 (see spec FR-001..004,
012, 013). Adds GET /api/v1/connect/{client}/preview returning the exact
change a subsequent connect would make — target config_path, server_key,
entry name, and the exact entry contents — WITHOUT modifying the file or
creating a backup.
Preview==write guarantee: buildServerEntry is now the single construction
path for every client, including Codex/TOML (connectTOML previously built
its {url} entry inline). Preview derives its entry from the same function
the write uses, so what is previewed equals what is written (FR-002); a
table-driven test connects each supported client and asserts the written
file entry deep-equals the shared constructor output across JSON and TOML.
API-key honesty (FR-004): the embedded apikey is masked in the payload
(entry, entry_text) while contains_api_key flags that a credential is
written; the real key never appears in the preview (verified by test that
marshals the whole payload and asserts the secret is absent).
Spec 075 access states (FR-012): the on-demand read used to classify
create-vs-overwrite degrades to access_state=absent|malformed and a denial
returns the same typed AccessError (403 + remediation) as connect/disconnect.
- internal/connect/preview.go: Service.Preview, maskURLAPIKey, entry render
- internal/connect/clients.go: buildServerEntry gains the codex case
- internal/connect/connect.go: connectTOML uses buildServerEntry
- internal/httpapi: handler + route + tests (masked, no side effects, 403)
- oas: regenerated for the new endpoint (make swagger)
Tests: preview-equals-write (JSON+TOML), masking, entry_exists (create vs
overwrite), no-side-effects (no write, no backup, mtime unchanged), absent/
malformed/denied degradation. go test -race ./internal/connect/... and
./internal/httpapi/... pass; golangci-lint v2 clean.
Related #793
Co-Authored-By: Claude Fable 5
---
internal/connect/clients.go | 7 +
internal/connect/connect.go | 7 +-
internal/connect/preview.go | 170 ++++++++++++++++
internal/connect/preview_test.go | 339 +++++++++++++++++++++++++++++++
internal/httpapi/connect.go | 49 +++++
internal/httpapi/connect_test.go | 82 ++++++++
internal/httpapi/server.go | 1 +
oas/docs.go | 2 +-
oas/swagger.yaml | 49 +++++
9 files changed, 701 insertions(+), 5 deletions(-)
create mode 100644 internal/connect/preview.go
create mode 100644 internal/connect/preview_test.go
diff --git a/internal/connect/clients.go b/internal/connect/clients.go
index ecdfa0a4c..1df43ebb1 100644
--- a/internal/connect/clients.go
+++ b/internal/connect/clients.go
@@ -211,6 +211,13 @@ func buildServerEntry(clientID, mcpURL string) map[string]interface{} {
"type": "http",
"url": mcpURL,
}
+ case "codex":
+ // Codex speaks TOML; the entry is a single url key under
+ // [mcp_servers.]. Constructed here (not inline in connectTOML) so
+ // preview and write share the one source of truth (Spec 078 FR-002).
+ return map[string]interface{}{
+ "url": mcpURL,
+ }
case "gemini":
return map[string]interface{}{
"httpUrl": mcpURL,
diff --git a/internal/connect/connect.go b/internal/connect/connect.go
index 6326c3e50..3d2d9e0d1 100644
--- a/internal/connect/connect.go
+++ b/internal/connect/connect.go
@@ -541,10 +541,9 @@ func (s *Service) connectTOML(client *ClientDef, cfgPath, serverName, mcpURL str
return nil, fmt.Errorf("backup failed: %w", err)
}
- // Build Codex entry
- entry := map[string]interface{}{
- "url": mcpURL,
- }
+ // Build Codex entry via the shared constructor so what connect writes is
+ // exactly what preview renders (Spec 078 FR-002).
+ entry := buildServerEntry(client.ID, mcpURL)
serversMap[serverName] = entry
data["mcp_servers"] = serversMap
diff --git a/internal/connect/preview.go b/internal/connect/preview.go
new file mode 100644
index 000000000..39f7088a0
--- /dev/null
+++ b/internal/connect/preview.go
@@ -0,0 +1,170 @@
+package connect
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "regexp"
+
+ "github.com/BurntSushi/toml"
+)
+
+// apiKeyMask is the placeholder substituted for the real apikey query value in a
+// preview. It is deliberately human-readable (not percent-encoded) so the user
+// plainly sees a credential is written without the secret ever leaving the core
+// in a preview payload, log, or telemetry event (Spec 078 FR-004).
+const apiKeyMask = "••••" // ••••
+
+// apiKeyQueryPattern matches an apikey query parameter value in the MCP URL,
+// capturing the `?apikey=` / `&apikey=` prefix so only the value is replaced.
+var apiKeyQueryPattern = regexp.MustCompile(`([?&]apikey=)[^&]*`)
+
+// ConnectPreview describes the exact change a subsequent Connect would make to a
+// client config, WITHOUT modifying the file or creating a backup (Spec 078 US1).
+// The entry is derived from the same buildServerEntry used by the real write, so
+// what is previewed equals what is written for the same client and configuration
+// (FR-002); the embedded API key is masked for display (FR-004).
+type ConnectPreview struct {
+ Client string `json:"client"`
+ ConfigPath string `json:"config_path"`
+ Format string `json:"format"` // "json" | "toml"
+ ServerKey string `json:"server_key"` // mcpServers / servers / mcp_servers / mcp
+ ServerName string `json:"server_name"` // key written into the config ("mcpproxy")
+ Entry map[string]interface{} `json:"entry"` // exact entry (masked) that will be written
+ EntryText string `json:"entry_text"` // entry rendered in the client's format (masked)
+ EntryExists bool `json:"entry_exists"` // an entry with this name already exists (overwrite/force case)
+ ContainsAPIKey bool `json:"contains_api_key"` // the written URL embeds an apikey credential
+ Bridge bool `json:"bridge,omitempty"` // connects via a stdio bridge (config created if absent)
+ // AccessState classifies the on-demand config read used to determine
+ // EntryExists (Spec 075): accessible|absent|malformed. A denied read never
+ // reaches here — it is returned as a typed *AccessError (403 + remediation).
+ AccessState string `json:"access_state"`
+}
+
+// maskURLAPIKey replaces the apikey query value with a fixed mask, leaving the
+// rest of the URL (scheme, host, path, other params) intact. When no apikey is
+// present it returns the URL unchanged.
+func maskURLAPIKey(raw string) string {
+ return apiKeyQueryPattern.ReplaceAllString(raw, `${1}`+apiKeyMask)
+}
+
+// Preview computes the exact entry a Connect would write for the given client,
+// without modifying the config or creating a backup (Spec 078 FR-001). It reads
+// the config on demand only to classify create-vs-overwrite (FR-003) and to
+// resolve the Spec 075 access state; a permission denial surfaces as the same
+// typed *AccessError that connect/disconnect return (FR-012).
+func (s *Service) Preview(clientID, serverName string) (*ConnectPreview, error) {
+ client := FindClient(clientID)
+ if client == nil {
+ return nil, fmt.Errorf("unknown client: %s", clientID)
+ }
+ if !client.Supported {
+ return nil, fmt.Errorf("client %s is not supported: %s", client.Name, client.Reason)
+ }
+ if serverName == "" {
+ serverName = defaultServerName
+ }
+
+ cfgPath := ConfigPath(clientID, s.homeDir)
+ if cfgPath == "" {
+ return nil, fmt.Errorf("cannot determine config path for %s", clientID)
+ }
+
+ // Determine create-vs-overwrite via an on-demand read. This is the same
+ // scoped, explicit-action read semantics as GetStatus: only touched when the
+ // file exists, so an absent config raises no macOS App-Data prompt.
+ accessState := accessAbsent
+ entryExists := false
+ if _, statErr := os.Stat(cfgPath); statErr == nil {
+ raw, rerr := s.read(cfgPath)
+ if rerr != nil {
+ if classifyAccess(rerr) == accessDenied {
+ // A denial must surface the actionable remediation, never a
+ // misleading "no changes" preview (Spec 078 FR-012).
+ return nil, s.newAccessError(client, cfgPath, rerr)
+ }
+ accessState = classifyAccess(rerr)
+ } else {
+ exists, parsedOK := s.previewEntryExists(*client, raw, serverName)
+ if parsedOK {
+ accessState = accessAccessible
+ entryExists = exists
+ } else {
+ // Unparseable config: the preview cannot claim "create" or
+ // "overwrite" honestly; report malformed and let the UI degrade.
+ accessState = accessMalformed
+ }
+ }
+ }
+
+ // Build the entry from the SAME constructor the write uses, then mask the
+ // embedded credential for display. Because the real write also calls
+ // buildServerEntry, the masked entry differs from the written entry only in
+ // the apikey value — the shape and every other field are identical.
+ maskedURL := maskURLAPIKey(s.mcpURL())
+ maskedEntry := buildServerEntry(clientID, maskedURL)
+
+ entryText, err := renderEntrySnippet(client, serverName, maskedEntry)
+ if err != nil {
+ return nil, fmt.Errorf("render preview entry: %w", err)
+ }
+
+ return &ConnectPreview{
+ Client: clientID,
+ ConfigPath: cfgPath,
+ Format: client.Format,
+ ServerKey: client.ServerKey,
+ ServerName: serverName,
+ Entry: maskedEntry,
+ EntryText: entryText,
+ EntryExists: entryExists,
+ ContainsAPIKey: s.apiKey != "",
+ Bridge: client.Bridge,
+ AccessState: accessState,
+ }, nil
+}
+
+// previewEntryExists reports whether an entry under the exact serverName the
+// write would target already exists in the parsed config, and whether the bytes
+// parsed at all (parsedOK=false => malformed). It mirrors the create-vs-overwrite
+// decision connectJSON / connectTOML make (`serversMap[serverName]` presence),
+// so preview's classification matches the write's force behavior. It never
+// touches the filesystem — the caller supplies already-read bytes.
+func (s *Service) previewEntryExists(client ClientDef, raw []byte, serverName string) (exists, parsedOK bool) {
+ var data map[string]interface{}
+ if client.Format == "toml" {
+ if _, err := toml.Decode(string(raw), &data); err != nil {
+ return false, false
+ }
+ } else if err := unmarshalLenientJSON(raw, &data); err != nil {
+ return false, false
+ }
+
+ serversMap, ok := data[client.ServerKey].(map[string]interface{})
+ if !ok {
+ return false, true
+ }
+ _, exists = serversMap[serverName]
+ return exists, true
+}
+
+// renderEntrySnippet renders the entry as the merge-ready snippet in the
+// client's format: for JSON, the server-name-keyed object; for TOML, the
+// [mcp_servers.] table. This is what the UI shows in a monospace block as
+// the additive change.
+func renderEntrySnippet(client *ClientDef, serverName string, entry map[string]interface{}) (string, error) {
+ keyed := map[string]interface{}{serverName: entry}
+ if client.Format == "toml" {
+ nested := map[string]interface{}{client.ServerKey: keyed}
+ var buf bytes.Buffer
+ if err := toml.NewEncoder(&buf).Encode(nested); err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+ }
+ encoded, err := marshalJSONIndent(keyed)
+ if err != nil {
+ return "", err
+ }
+ return string(encoded), nil
+}
diff --git a/internal/connect/preview_test.go b/internal/connect/preview_test.go
new file mode 100644
index 000000000..2c2719698
--- /dev/null
+++ b/internal/connect/preview_test.go
@@ -0,0 +1,339 @@
+package connect
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/BurntSushi/toml"
+)
+
+// serviceWithKey builds a Service with a specific API key and isolated home,
+// pinning the Windows env vars the same way testService does.
+func serviceWithKey(t *testing.T, key string) (*Service, string) {
+ t.Helper()
+ home := t.TempDir()
+ t.Setenv("LOCALAPPDATA", filepath.Join(home, "AppData", "Local"))
+ t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming"))
+ return NewServiceWithHome("127.0.0.1:8080", key, home), home
+}
+
+// seedClientConfig writes a minimal, valid, empty config for the client so both
+// JSON and TOML Connect paths update an existing file uniformly (and opencode,
+// which refuses to create its own file, has one to update).
+func seedClientConfig(t *testing.T, home, clientID string) {
+ t.Helper()
+ cfgPath := ConfigPath(clientID, home)
+ if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ content := []byte("{}\n")
+ if FindClient(clientID).Format == "toml" {
+ content = []byte("")
+ }
+ if err := os.WriteFile(cfgPath, content, 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// supportedPreviewClients lists every supported client id so the preview==write
+// equivalence is asserted across both JSON and the TOML client (Spec 078 SC-004,
+// FR-013). opencode requires a pre-existing config file (Connect refuses to
+// create one) and is exercised with a seeded file in the loop.
+var supportedPreviewClients = []string{
+ "claude-code", "claude-desktop", "cursor", "windsurf",
+ "vscode", "codex", "gemini", "opencode",
+}
+
+// marshalEntry renders a config entry to canonical JSON (sorted keys) so a
+// []string arg slice from buildServerEntry and the []interface{} that survives a
+// JSON round-trip compare equal by content rather than Go type.
+func marshalEntry(t *testing.T, v interface{}) string {
+ t.Helper()
+ b, err := json.Marshal(v)
+ if err != nil {
+ t.Fatalf("marshal entry: %v", err)
+ }
+ return string(b)
+}
+
+// readWrittenEntry reads back the entry Connect wrote for serverName under the
+// client's server key, for both JSON and TOML formats.
+func readWrittenEntry(t *testing.T, svc *Service, clientID, serverName string) map[string]interface{} {
+ t.Helper()
+ client := FindClient(clientID)
+ cfgPath := ConfigPath(clientID, svc.homeDir)
+ raw, err := os.ReadFile(cfgPath)
+ if err != nil {
+ t.Fatalf("read written config %s: %v", cfgPath, err)
+ }
+ var data map[string]interface{}
+ if client.Format == "toml" {
+ if _, derr := toml.Decode(string(raw), &data); derr != nil {
+ t.Fatalf("decode written TOML: %v", derr)
+ }
+ } else if uerr := json.Unmarshal(raw, &data); uerr != nil {
+ t.Fatalf("decode written JSON: %v", uerr)
+ }
+ servers, ok := data[client.ServerKey].(map[string]interface{})
+ if !ok {
+ t.Fatalf("no %q section in written %s config", client.ServerKey, clientID)
+ }
+ entry, ok := servers[serverName].(map[string]interface{})
+ if !ok {
+ t.Fatalf("no %q entry in written %s config", serverName, clientID)
+ }
+ return entry
+}
+
+// TestPreview_EqualsWrite pins the core US1 guarantee: for every supported
+// client, the entry the preview is derived from is byte-for-byte the entry a
+// subsequent Connect writes — same key, same shape (Spec 078 FR-002, SC-004).
+// It compares against the SHARED constructor with the real (unmasked) URL, then
+// separately confirms the preview payload masks that URL.
+func TestPreview_EqualsWrite(t *testing.T) {
+ for _, clientID := range supportedPreviewClients {
+ clientID := clientID
+ t.Run(clientID, func(t *testing.T) {
+ svc, home := testServiceWithKey(t)
+ seedClientConfig(t, home, clientID)
+
+ preview, err := svc.Preview(clientID, "mcpproxy")
+ if err != nil {
+ t.Fatalf("Preview: %v", err)
+ }
+
+ res, err := svc.Connect(clientID, "mcpproxy", true)
+ if err != nil {
+ t.Fatalf("Connect: %v", err)
+ }
+ if !res.Success {
+ t.Fatalf("Connect not successful: %+v", res)
+ }
+
+ written := readWrittenEntry(t, svc, clientID, "mcpproxy")
+
+ // The write uses buildServerEntry(clientID, realURL); the shared
+ // constructor is the single source of truth for preview and write.
+ wantUnmasked := buildServerEntry(clientID, svc.mcpURL())
+ if got, want := marshalEntry(t, written), marshalEntry(t, wantUnmasked); got != want {
+ t.Fatalf("written entry != shared constructor output\n got: %s\nwant: %s", got, want)
+ }
+
+ // The preview payload masks the credential: its entry equals the
+ // shared constructor fed the masked URL, and never the real key.
+ wantMasked := buildServerEntry(clientID, maskURLAPIKey(svc.mcpURL()))
+ if got, want := marshalEntry(t, preview.Entry), marshalEntry(t, wantMasked); got != want {
+ t.Fatalf("preview entry != masked constructor output\n got: %s\nwant: %s", got, want)
+ }
+ })
+ }
+}
+
+// TestPreview_MasksAPIKey verifies the real API key never appears anywhere in
+// the preview payload while ContainsAPIKey honestly flags that a credential is
+// written, and the base URL stays visible (Spec 078 FR-004).
+func TestPreview_MasksAPIKey(t *testing.T) {
+ const secret = "super-secret-key-1234"
+ svc, home := serviceWithKey(t, secret)
+ seedClientConfig(t, home, "claude-code")
+
+ preview, err := svc.Preview("claude-code", "mcpproxy")
+ if err != nil {
+ t.Fatalf("Preview: %v", err)
+ }
+
+ payload, err := json.Marshal(preview)
+ if err != nil {
+ t.Fatalf("marshal preview: %v", err)
+ }
+ if strings.Contains(string(payload), secret) {
+ t.Fatalf("real API key leaked into preview payload: %s", payload)
+ }
+ if !preview.ContainsAPIKey {
+ t.Fatal("ContainsAPIKey must be true when the URL embeds a credential")
+ }
+ if !strings.Contains(preview.EntryText, apiKeyMask) {
+ t.Fatalf("EntryText should show the mask, got: %s", preview.EntryText)
+ }
+ if !strings.Contains(preview.EntryText, "http://127.0.0.1:8080/mcp") {
+ t.Fatalf("EntryText should keep the base URL visible, got: %s", preview.EntryText)
+ }
+}
+
+// TestPreview_NoAPIKey: with no key configured, ContainsAPIKey is false and no
+// apikey param is rendered.
+func TestPreview_NoAPIKey(t *testing.T) {
+ svc, home := testService(t) // no key
+ seedClientConfig(t, home, "cursor")
+
+ preview, err := svc.Preview("cursor", "mcpproxy")
+ if err != nil {
+ t.Fatalf("Preview: %v", err)
+ }
+ if preview.ContainsAPIKey {
+ t.Fatal("ContainsAPIKey must be false when no key is configured")
+ }
+ if strings.Contains(preview.EntryText, "apikey") {
+ t.Fatalf("no apikey should be present, got: %s", preview.EntryText)
+ }
+}
+
+// TestPreview_EntryExists distinguishes a create from an overwrite of an
+// existing same-named entry (Spec 078 FR-003).
+func TestPreview_EntryExists(t *testing.T) {
+ svc, home := testService(t)
+ cfgPath := ConfigPath("claude-code", home)
+ if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ // Config with a user-created "mcpproxy" entry — the overwrite case.
+ if err := os.WriteFile(cfgPath, []byte(`{"mcpServers":{"mcpproxy":{"type":"http","url":"http://old"}}}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ preview, err := svc.Preview("claude-code", "mcpproxy")
+ if err != nil {
+ t.Fatalf("Preview: %v", err)
+ }
+ if !preview.EntryExists {
+ t.Fatal("expected EntryExists=true for a pre-existing same-named entry")
+ }
+ if preview.AccessState != accessAccessible {
+ t.Fatalf("expected accessible, got %s", preview.AccessState)
+ }
+
+ // A fresh config without the entry is a create.
+ if err := os.WriteFile(cfgPath, []byte(`{"mcpServers":{"other":{"url":"http://x"}}}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ preview2, err := svc.Preview("claude-code", "mcpproxy")
+ if err != nil {
+ t.Fatalf("Preview: %v", err)
+ }
+ if preview2.EntryExists {
+ t.Fatal("expected EntryExists=false when no same-named entry present")
+ }
+}
+
+// TestPreview_NoSideEffects: a preview must not modify the config nor create a
+// backup (Spec 078 FR-001, US1 independent test).
+func TestPreview_NoSideEffects(t *testing.T) {
+ svc, home := testService(t)
+ cfgPath := ConfigPath("claude-code", home)
+ if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ original := []byte(`{"mcpServers":{"other":{"url":"http://x"}}}`)
+ if err := os.WriteFile(cfgPath, original, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ before, err := os.Stat(cfgPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := svc.Preview("claude-code", "mcpproxy"); err != nil {
+ t.Fatalf("Preview: %v", err)
+ }
+
+ after, err := os.Stat(cfgPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !after.ModTime().Equal(before.ModTime()) {
+ t.Fatal("preview must not modify the config file (mtime changed)")
+ }
+ got, err := os.ReadFile(cfgPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(got) != string(original) {
+ t.Fatalf("preview altered config contents:\n%s", got)
+ }
+ // No backup file must have been created.
+ entries, err := os.ReadDir(filepath.Dir(cfgPath))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range entries {
+ if strings.Contains(e.Name(), ".bak.") {
+ t.Fatalf("preview created a backup file: %s", e.Name())
+ }
+ }
+}
+
+// TestPreview_AbsentConfig: previewing a client with no config file is a create
+// with access_state=absent and no error (bridge and non-bridge alike).
+func TestPreview_AbsentConfig(t *testing.T) {
+ svc, _ := testService(t)
+ preview, err := svc.Preview("claude-desktop", "mcpproxy")
+ if err != nil {
+ t.Fatalf("Preview: %v", err)
+ }
+ if preview.EntryExists {
+ t.Fatal("absent config cannot have an existing entry")
+ }
+ if preview.AccessState != accessAbsent {
+ t.Fatalf("expected absent, got %s", preview.AccessState)
+ }
+ if !preview.Bridge {
+ t.Fatal("claude-desktop is a bridge client")
+ }
+}
+
+// TestPreview_Malformed: an unparseable config degrades to access_state=malformed
+// rather than a misleading "create" (Spec 078 FR-012).
+func TestPreview_Malformed(t *testing.T) {
+ svc, home := testService(t)
+ cfgPath := ConfigPath("claude-code", home)
+ if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(cfgPath, []byte(`{not valid json at all`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ preview, err := svc.Preview("claude-code", "mcpproxy")
+ if err != nil {
+ t.Fatalf("Preview should not hard-error on malformed config: %v", err)
+ }
+ if preview.AccessState != accessMalformed {
+ t.Fatalf("expected malformed, got %s", preview.AccessState)
+ }
+}
+
+// TestPreview_Denied: a permission-denied config read surfaces the same typed
+// AccessError as connect/disconnect (Spec 078 FR-012), so the REST layer maps
+// it to 403 + remediation.
+func TestPreview_Denied(t *testing.T) {
+ home := t.TempDir()
+ cfgPath := ConfigPath("claude-code", home)
+ if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(cfgPath, []byte(`{"mcpServers":{}}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ svc := NewServiceWithReader("127.0.0.1:8080", "", home, func(string) ([]byte, error) {
+ return nil, os.ErrPermission
+ })
+ _, err := svc.Preview("claude-code", "mcpproxy")
+ if err == nil {
+ t.Fatal("expected an AccessError for a denied read")
+ }
+ var accessErr *AccessError
+ if !errors.As(err, &accessErr) {
+ t.Fatalf("expected *AccessError, got %T: %v", err, err)
+ }
+}
+
+// TestPreview_UnknownClient errors, matching Connect's contract.
+func TestPreview_UnknownClient(t *testing.T) {
+ svc, _ := testService(t)
+ if _, err := svc.Preview("not-a-client", "mcpproxy"); err == nil {
+ t.Fatal("expected error for unknown client")
+ }
+}
diff --git a/internal/httpapi/connect.go b/internal/httpapi/connect.go
index d40228377..f921402a8 100644
--- a/internal/httpapi/connect.go
+++ b/internal/httpapi/connect.go
@@ -77,6 +77,55 @@ func (s *Server) handleGetConnectClientStatus(w http.ResponseWriter, r *http.Req
s.writeSuccess(w, status)
}
+// handleConnectClientPreview godoc
+// @Summary Preview the change a connect would make (no write)
+// @Description Returns the exact entry a subsequent connect would add to the client's
+// @Description config — target path, server key, entry name, and entry contents — WITHOUT
+// @Description modifying the file or creating a backup (Spec 078 US1). The embedded API key
+// @Description is masked in the payload; contains_api_key flags that a credential is written.
+// @Description entry_exists distinguishes a create from an overwrite of a same-named entry.
+// @Description Reads the config on demand to classify create-vs-overwrite, so on macOS this
+// @Description may raise an App-Data privacy prompt; a denial returns 403 + remediation.
+// @Tags connect
+// @Produce json
+// @Security ApiKeyAuth
+// @Security ApiKeyQuery
+// @Param client path string true "Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)"
+// @Success 200 {object} contracts.APIResponse "ConnectPreview"
+// @Failure 403 {object} contracts.ErrorResponse "Permission denied (macOS App-Data block)"
+// @Failure 404 {object} contracts.ErrorResponse "Unknown client"
+// @Failure 503 {object} contracts.ErrorResponse "Service unavailable"
+// @Router /api/v1/connect/{client}/preview [get]
+func (s *Server) handleConnectClientPreview(w http.ResponseWriter, r *http.Request) {
+ svc := s.getConnectService()
+ if svc == nil {
+ s.writeError(w, r, http.StatusServiceUnavailable, "connect service not available")
+ return
+ }
+
+ clientID := chi.URLParam(r, "client")
+ if clientID == "" {
+ s.writeError(w, r, http.StatusBadRequest, "client ID is required")
+ return
+ }
+
+ preview, err := svc.Preview(clientID, "")
+ if err != nil {
+ // A macOS App-Data (TCC) denial during the on-demand read surfaces as
+ // 403 + remediation, matching connect/disconnect (Spec 078 FR-012).
+ if s.writeIfAccessDenied(w, r, err) {
+ return
+ }
+ if connect.FindClient(clientID) == nil {
+ s.writeError(w, r, http.StatusNotFound, err.Error())
+ return
+ }
+ s.writeError(w, r, http.StatusBadRequest, err.Error())
+ return
+ }
+ s.writeSuccess(w, preview)
+}
+
// handleConnectClient godoc
// @Summary Connect MCPProxy to a client
// @Description Register MCPProxy as an MCP server in the specified client's configuration file.
diff --git a/internal/httpapi/connect_test.go b/internal/httpapi/connect_test.go
index f044af4a8..f9d971671 100644
--- a/internal/httpapi/connect_test.go
+++ b/internal/httpapi/connect_test.go
@@ -282,3 +282,85 @@ func TestHandleConnectClient_TrueConflictStillReturns409(t *testing.T) {
assert.Equal(t, "already_exists", resp.Data.Action)
assert.NotEmpty(t, resp.Error)
}
+
+// TestHandleConnectClientPreview_MaskedNoSideEffects exercises the Spec 078 US1
+// preview endpoint end-to-end: it returns the exact entry a connect would write
+// with the API key masked, does not modify the config, and creates no backup.
+func TestHandleConnectClientPreview_MaskedNoSideEffects(t *testing.T) {
+ logger := zap.NewNop().Sugar()
+ mockCtrl := &mockRoutingController{apiKey: "test-key", routingMode: "retrieve_tools"}
+ srv := NewServer(mockCtrl, logger, nil)
+ home := t.TempDir()
+
+ cfgPath := connect.ConfigPath("claude-code", home)
+ require.NoError(t, os.MkdirAll(filepath.Dir(cfgPath), 0o755))
+ original := []byte(`{"mcpServers":{"other":{"url":"http://x"}}}`)
+ require.NoError(t, os.WriteFile(cfgPath, original, 0o644))
+
+ const secret = "rest-secret-key-9999"
+ svc := connect.NewServiceWithHome("127.0.0.1:8080", secret, home)
+ srv.SetConnectService(svc)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/connect/claude-code/preview", http.NoBody)
+ req.Header.Set("X-API-Key", "test-key")
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusOK, w.Code)
+ rawBody := w.Body.Bytes()
+ assert.NotContains(t, string(rawBody), secret, "real API key must not appear in the preview payload")
+
+ var resp struct {
+ Success bool `json:"success"`
+ Data connect.ConnectPreview `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(rawBody, &resp))
+ assert.True(t, resp.Success)
+ assert.Equal(t, cfgPath, resp.Data.ConfigPath)
+ assert.Equal(t, "mcpServers", resp.Data.ServerKey)
+ assert.Equal(t, "mcpproxy", resp.Data.ServerName)
+ assert.True(t, resp.Data.ContainsAPIKey)
+ assert.False(t, resp.Data.EntryExists)
+ assert.Equal(t, "accessible", resp.Data.AccessState)
+ assert.Contains(t, resp.Data.EntryText, "http://127.0.0.1:8080/mcp")
+
+ // No write, no backup.
+ after, err := os.ReadFile(cfgPath)
+ require.NoError(t, err)
+ assert.Equal(t, string(original), string(after))
+ entries, err := os.ReadDir(filepath.Dir(cfgPath))
+ require.NoError(t, err)
+ for _, e := range entries {
+ assert.NotContains(t, e.Name(), ".bak.", "preview must not create a backup")
+ }
+}
+
+// TestHandleConnectClientPreview_DeniedReturns403 asserts a permission-denied
+// config read during preview surfaces 403 + remediation, matching connect.
+func TestHandleConnectClientPreview_DeniedReturns403(t *testing.T) {
+ logger := zap.NewNop().Sugar()
+ mockCtrl := &mockRoutingController{apiKey: "test-key", routingMode: "retrieve_tools"}
+ srv := NewServer(mockCtrl, logger, nil)
+ home := t.TempDir()
+
+ cfgPath := connect.ConfigPath("claude-code", home)
+ require.NoError(t, os.MkdirAll(filepath.Dir(cfgPath), 0o755))
+ require.NoError(t, os.WriteFile(cfgPath, []byte(`{}`), 0o644))
+
+ svc := connect.NewServiceWithReader("127.0.0.1:8080", "", home, denyingReader)
+ srv.SetConnectService(svc)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/connect/claude-code/preview", http.NoBody)
+ req.Header.Set("X-API-Key", "test-key")
+ w := httptest.NewRecorder()
+ srv.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ var resp struct {
+ Success bool `json:"success"`
+ Error string `json:"error"`
+ }
+ require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
+ assert.False(t, resp.Success)
+ assert.Contains(t, resp.Error, "tccutil reset SystemPolicyAppData")
+}
diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go
index 4a80bee0c..53b909f8b 100644
--- a/internal/httpapi/server.go
+++ b/internal/httpapi/server.go
@@ -771,6 +771,7 @@ func (s *Server) setupRoutes() {
// Client connect/disconnect
r.Get("/connect", s.handleGetConnectStatus)
r.Get("/connect/{client}", s.handleGetConnectClientStatus)
+ r.Get("/connect/{client}/preview", s.handleConnectClientPreview)
r.Post("/connect/{client}", s.handleConnectClient)
r.Delete("/connect/{client}", s.handleDisconnectClient)
diff --git a/oas/docs.go b/oas/docs.go
index 63f000924..dc1ad91ac 100644
--- a/oas/docs.go
+++ b/oas/docs.go
@@ -9,7 +9,7 @@ const docTemplate = `{
"components": {"schemas":{"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_max_size_mb":{"description":"Max total activity-log size in MB before pruning oldest (default: 256, 0=disabled)","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"allow_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. Set true ONLY when you\nintentionally run a trusted registry mirror on an internal/private address.","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_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"},"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_result_size_chars":{"description":"Advertised on every tool as ` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; raises Claude Code's inline-response ceiling from 50k to up to 500k chars. Set to 0 to disable.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of sensitive\nheader values (Authorization, X-API-Key, Cookie, …) in responses\nfrom the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + ` REST\nAPI, and the SSE event stream.\n\nDefault false — sensitive header values are surfaced as\n` + "`" + `***REDACTED***` + "`" + ` so an MCP agent cannot read Bearer tokens / API\nkeys out of another upstream's config (PR #425).\n\nThe Web UI / macOS tray edit forms work without seeing the real\nvalues: PATCH /api/v1/servers/{id} deep-merges (omitted keys are\npreserved, see ` + "`" + `headers_remove` + "`" + ` / ` + "`" + `env_remove` + "`" + ` for explicit\ndeletes), so clients compute a diff and only send the keys that\nactually changed. Redacted-but-unchanged values never round-trip\n— the backend keeps the real string. Set this to true if a\ndownstream tool genuinely needs raw values in the response.","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"security":{"$ref":"#/components/schemas/config.SecurityConfig"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_discovery_interval":{"type":"string"},"tool_response_limit":{"type":"integer"},"tool_response_session_risk_warning":{"description":"ToolResponseSessionRiskWarning controls whether the prose ` + "`" + `warning` + "`" + ` field\nis included in the ` + "`" + `session_risk` + "`" + ` object returned by ` + "`" + `retrieve_tools` + "`" + `.\nThe structured fields (level, lethal_trifecta, has_open_world_tools, etc.)\nare always included. Default: false (quiet for LLM clients) — see issue #406.\nMost tools lack annotations, so the MCP-spec defaults treat them as fully\npermissive across all three risk axes, which makes the prose warning fire\non almost every call and wastes tokens.","type":"boolean"},"tools_limit":{"type":"integer"},"top_k":{"description":"Deprecated: TopK is superseded by ToolsLimit and has no runtime effect. Kept for backward compatibility.","type":"integer"},"tray_endpoint":{"description":"Tray endpoint override (unix:// or npipe://)","type":"string"}},"type":"object"},"config.CustomPattern":{"properties":{"category":{"description":"Category (defaults to \"custom\")","type":"string"},"keywords":{"description":"Keywords to match (mutually exclusive with Regex)","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Unique identifier for this pattern","type":"string"},"regex":{"description":"Regex pattern (mutually exclusive with Keywords)","type":"string"},"severity":{"description":"Risk level: critical, high, medium, low","type":"string"}},"type":"object"},"config.DockerIsolationConfig":{"description":"Docker isolation settings","properties":{"cpu_limit":{"description":"CPU limit for containers","type":"string"},"default_images":{"additionalProperties":{"type":"string"},"description":"Map of runtime type to Docker image","type":"object"},"enable_cache_volume":{"description":"Mount shared cache volumes for faster restarts (default: true)","type":"boolean"},"enabled":{"description":"Global enable/disable for Docker isolation (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":{"auto_scan_quarantined":{"type":"boolean"},"integrity_check_interval":{"type":"string"},"integrity_check_on_restart":{"type":"boolean"},"runtime_read_only":{"type":"boolean"},"runtime_tmpfs_size":{"type":"string"},"scan_timeout_default":{"type":"string"},"scanner_disable_no_new_privileges":{"description":"ScannerDisableNoNewPrivileges, when true, omits the\n` + "`" + `--security-opt no-new-privileges` + "`" + ` flag from scanner container runs.\n\nBackground: snap-installed Docker on Ubuntu confines dockerd under the\n` + "`" + `snap.docker.dockerd` + "`" + ` AppArmor profile. When runc tries to transition\nthe container into the inner ` + "`" + `docker-default` + "`" + ` profile to exec the\nentrypoint, AppArmor refuses the transition because NO_NEW_PRIVS\nforbids privilege/profile changes on exec — the result is EPERM\n(\"operation not permitted\") and every scanner fails immediately.\n\nSet this to true ONLY on hosts hitting that incompatibility. Scanner\ncontainers still run with read-only rootfs, tmpfs /tmp, no-network by\ndefault, and read-only source mounts, so the marginal isolation loss\nis small. The preferred fix remains replacing snap docker with a\ndistro-packaged docker.","type":"boolean"},"scanner_fetch_package_source":{"description":"ScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"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"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/config.OAuthConfig"},"protocol":{"description":"stdio, http, sse, streamable-http, auto","type":"string"},"quarantined":{"description":"Security quarantine status","type":"boolean"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets a disconnected server","type":"boolean"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TelemetryConfig":{"description":"Telemetry settings (Spec 036)","properties":{"anonymous_id":{"description":"Auto-generated UUIDv4","type":"string"},"anonymous_id_created_at":{"description":"Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"},"last_reported_version":{"description":"Upgrade funnel","type":"string"},"last_startup_outcome":{"description":"success|port_conflict|db_locked|...","type":"string"},"notice_shown":{"description":"First-run notice flag","type":"boolean"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"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"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\"","type":"string"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"arguments":{"description":"Tool call arguments","type":"object"},"detection_types":{"description":"List of detection types found","items":{"type":"string"},"type":"array","uniqueItems":false},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP session ID for correlation","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"}},"type":"object"},"contracts.ActivitySource":{"description":"How activity was triggered: \"mcp\", \"cli\", \"api\"","type":"string","x-enum-varnames":["ActivitySourceMCP","ActivitySourceCLI","ActivitySourceAPI"]},"contracts.ActivitySummaryResponse":{"properties":{"blocked_count":{"description":"Count of blocked activities","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"contracts.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostic":{"description":"Spec 044 — structured diagnostic error and stable error code. Both\nare populated when the server is in a failed state and the error\nhas been classified by internal/diagnostics. Healthy servers omit\nthese fields.","properties":{"cause":{"type":"string"},"code":{"type":"string"},"detected_at":{"type":"string"},"docs_url":{"type":"string"},"fix_steps":{"items":{"$ref":"#/components/schemas/contracts.DiagnosticFixStep"},"type":"array","uniqueItems":false},"severity":{"type":"string"},"user_message":{"type":"string"}},"type":"object"},"contracts.DiagnosticFixStep":{"properties":{"command":{"type":"string"},"destructive":{"type":"boolean"},"fixer_key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.EditRegistrySourceRequest":{"properties":{"name":{"description":"new display name","type":"string"},"servers_url":{"description":"explicit servers-collection URL","type":"string"},"url":{"description":"new base/servers https URL","type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.FindingCounts":{"properties":{"dangerous":{"description":"Tool poisoning, active prompt injection","type":"integer"},"info":{"description":"Low-severity CVEs, informational","type":"integer"},"total":{"type":"integer"},"warning":{"description":"Rug pull, supply chain CVEs with exploits","type":"integer"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object","type":"object"},"config_path":{"description":"Path to config file","type":"string"}},"type":"object"},"contracts.GetRegistriesResponse":{"properties":{"registries":{"items":{"$ref":"#/components/schemas/contracts.Registry"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerLogsResponse":{"properties":{"count":{"type":"integer"},"logs":{"items":{"$ref":"#/components/schemas/contracts.LogEntry"},"type":"array","uniqueItems":false},"server_name":{"type":"string"}},"type":"object"},"contracts.GetServerToolCallsResponse":{"properties":{"server_name":{"type":"string"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerToolsResponse":{"properties":{"count":{"type":"integer"},"server_name":{"type":"string"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GetServersResponse":{"properties":{"servers":{"items":{"$ref":"#/components/schemas/contracts.Server"},"type":"array","uniqueItems":false},"stats":{"$ref":"#/components/schemas/contracts.ServerStats"}},"type":"object"},"contracts.GetSessionDetailResponse":{"properties":{"session":{"$ref":"#/components/schemas/contracts.MCPSession"}},"type":"object"},"contracts.GetSessionsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"sessions":{"items":{"$ref":"#/components/schemas/contracts.MCPSession"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetToolCallDetailResponse":{"properties":{"tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"}},"type":"object"},"contracts.GetToolCallsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GlobalToolsResponse":{"properties":{"failed_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"partial":{"type":"boolean"},"stats":{"$ref":"#/components/schemas/contracts.GlobalToolsStats"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GlobalToolsStats":{"properties":{"disabled":{"type":"integer"},"enabled":{"type":"integer"},"pending_approval":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.HealthStatus":{"description":"Unified health status calculated by the backend","properties":{"action":{"description":"Action is the suggested fix action: \"login\", \"restart\", \"enable\", \"approve\", \"view_logs\", \"set_secret\", \"configure\", or \"\" (none)","type":"string"},"admin_state":{"description":"AdminState indicates the admin state: \"enabled\", \"disabled\", or \"quarantined\"","type":"string"},"detail":{"description":"Detail is an optional longer explanation of the status","type":"string"},"level":{"description":"Level indicates the health level: \"healthy\", \"degraded\", or \"unhealthy\"","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"}},"type":"object"},"contracts.InfoEndpoints":{"description":"Available API endpoints","properties":{"http":{"description":"HTTP endpoint address (e.g., \"127.0.0.1:8080\")","type":"string"},"socket":{"description":"Unix socket path (empty if disabled)","type":"string"}},"type":"object"},"contracts.InfoResponse":{"properties":{"endpoints":{"$ref":"#/components/schemas/contracts.InfoEndpoints"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"network_mode":{"type":"string"},"timeout":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationDefaults":{"description":"IsolationDefaults exposes the resolved baseline values that\nwould apply when no per-server override is set. Populated on\nlist/get responses; never consumed on PATCH requests.","properties":{"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"runtime_type":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.LogEntry":{"properties":{"fields":{"type":"object"},"level":{"type":"string"},"message":{"type":"string"},"server":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.MCPSession":{"properties":{"client_name":{"type":"string"},"client_version":{"type":"string"},"end_time":{"type":"string"},"experimental":{"items":{"type":"string"},"type":"array","uniqueItems":false},"has_roots":{"description":"MCP Client Capabilities","type":"boolean"},"has_sampling":{"type":"boolean"},"id":{"type":"string"},"last_activity":{"type":"string"},"start_time":{"type":"string"},"status":{"type":"string"},"tool_call_count":{"type":"integer"},"total_tokens":{"type":"integer"}},"type":"object"},"contracts.MetadataStatus":{"properties":{"authorization_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"error":{"type":"string"},"found":{"type":"boolean"},"url_checked":{"type":"string"}},"type":"object"},"contracts.MissingSecretInfo":{"properties":{"secret_name":{"type":"string"},"used_by":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.NPMPackageInfo":{"properties":{"exists":{"type":"boolean"},"install_cmd":{"type":"string"}},"type":"object"},"contracts.OAuthConfig":{"properties":{"auth_url":{"type":"string"},"client_id":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_port":{"type":"integer"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_expires_at":{"description":"When the OAuth token expires","type":"string"},"token_url":{"type":"string"},"token_valid":{"description":"Whether token is currently valid","type":"boolean"}},"type":"object"},"contracts.OAuthErrorDetails":{"description":"Structured discovery/failure details","properties":{"authorization_server_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"dcr_status":{"$ref":"#/components/schemas/contracts.DCRStatus"},"protected_resource_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"server_url":{"type":"string"}},"type":"object"},"contracts.OAuthFlowError":{"properties":{"correlation_id":{"description":"Flow tracking ID for log correlation","type":"string"},"debug_hint":{"description":"CLI command for log lookup","type":"string"},"details":{"$ref":"#/components/schemas/contracts.OAuthErrorDetails"},"error_code":{"description":"Machine-readable error code (e.g., OAUTH_NO_METADATA)","type":"string"},"error_type":{"description":"Category of OAuth runtime failure","type":"string"},"message":{"description":"Human-readable error description","type":"string"},"request_id":{"description":"HTTP request ID (from PR #237)","type":"string"},"server_name":{"description":"Server that failed OAuth","type":"string"},"success":{"description":"Always false","type":"boolean"},"suggestion":{"description":"Actionable remediation hint","type":"string"}},"type":"object"},"contracts.OAuthIssue":{"properties":{"documentation_url":{"type":"string"},"error":{"type":"string"},"issue":{"type":"string"},"missing_params":{"items":{"type":"string"},"type":"array","uniqueItems":false},"resolution":{"type":"string"},"server_name":{"type":"string"}},"type":"object"},"contracts.OAuthRequirement":{"properties":{"expires_at":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"state":{"type":"string"}},"type":"object"},"contracts.OAuthStartResponse":{"properties":{"auth_url":{"description":"Authorization URL (always included for manual use)","type":"string"},"browser_error":{"description":"Error message if browser launch failed","type":"string"},"browser_opened":{"description":"Whether browser launch succeeded","type":"boolean"},"correlation_id":{"description":"UUID for tracking this flow","type":"string"},"message":{"description":"Human-readable status message","type":"string"},"server_name":{"description":"Name of the server being authenticated","type":"string"},"success":{"description":"Always true for successful start","type":"boolean"}},"type":"object"},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"blocked_count":{"description":"Number of disabled (blocked) tools","type":"integer"},"changed_count":{"description":"Number of tools whose description/schema changed since approval","type":"integer"},"pending_count":{"description":"Number of newly discovered tools awaiting approval","type":"integer"}},"type":"object"},"contracts.RefreshRegistryResponse":{"properties":{"cleared":{"description":"number of cached entries dropped","type":"integer"},"registry_id":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary scan pass. \"degraded\" status means\nScannersFailed \u003e 0, so the risk score reflects an incomplete scan and a\nlow score should not be read as a trustworthy all-clear (MCP-2401).","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"degraded\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"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"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"security_scan":{"$ref":"#/components/schemas/contracts.SecurityScanSummary"},"should_retry":{"type":"boolean"},"source_registry_id":{"description":"MCP-901 — registry provenance of an upstream that was added from a\nregistry. SourceRegistryID names the source registry (empty for\nmanually-configured servers); SourceRegistryProvenance is the trust tag\nrecorded at add time (\"official/trusted\" or \"custom/unverified\"). Both\nare projected from config.ServerConfig so the approval/quarantine view\ncan render an \"added from \u003cregistry\u003e · unverified\" origin badge. Optional\nand omitted when empty — clients that pre-date this treat them as absent.","type":"string"},"source_registry_provenance":{"type":"string"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"updated":{"type":"string"},"url":{"type":"string"},"user_logged_out":{"description":"True if user explicitly logged out (prevents auto-reconnection)","type":"boolean"},"working_dir":{"type":"string"}},"type":"object"},"contracts.ServerActionResponse":{"properties":{"action":{"type":"string"},"async":{"type":"boolean"},"server":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ServerStats":{"properties":{"connected_servers":{"type":"integer"},"docker_containers":{"type":"integer"},"quarantined_servers":{"type":"integer"},"token_metrics":{"$ref":"#/components/schemas/contracts.ServerTokenMetrics"},"total_servers":{"type":"integer"},"total_tools":{"type":"integer"}},"type":"object"},"contracts.ServerTokenMetrics":{"properties":{"average_query_result_size":{"description":"Typical retrieve_tools output (tokens)","type":"integer"},"per_server_tool_list_sizes":{"additionalProperties":{"type":"integer"},"description":"Token size per server","type":"object"},"saved_tokens":{"description":"Difference","type":"integer"},"saved_tokens_percentage":{"description":"Percentage saved","type":"number"},"total_server_tool_list_size":{"description":"All upstream tools combined (tokens)","type":"integer"}},"type":"object"},"contracts.SuccessResponse":{"properties":{"data":{"type":"object"},"success":{"type":"boolean"}},"type":"object"},"contracts.TokenMetrics":{"description":"Token usage metrics (nil for older records)","properties":{"encoding":{"description":"Encoding used (e.g., cl100k_base)","type":"string"},"estimated_cost":{"description":"Optional cost estimate","type":"number"},"input_tokens":{"description":"Tokens in the request","type":"integer"},"model":{"description":"Model used for tokenization","type":"string"},"output_tokens":{"description":"Tokens in the response","type":"integer"},"total_tokens":{"description":"Total tokens (input + output)","type":"integer"},"truncated_tokens":{"description":"Tokens removed by truncation","type":"integer"},"was_truncated":{"description":"Whether response was truncated","type":"boolean"}},"type":"object"},"contracts.Tool":{"properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"approval_status":{"type":"string"},"config_denied":{"description":"ConfigDenied is true when the tool is denied by the server's static\nenabled_tools / disabled_tools config. The user cannot override this toggle.","type":"boolean"},"description":{"type":"string"},"disabled":{"description":"Disabled mirrors ToolApprovalRecord.Disabled so per-tool enable state is\navailable without a second round-trip to the approvals endpoint. Absent\nin the JSON when false (default) to keep responses compact.","type":"boolean"},"last_used":{"type":"string"},"name":{"type":"string"},"schema":{"type":"object"},"server_name":{"type":"string"},"usage":{"type":"integer"}},"type":"object"},"contracts.ToolAnnotation":{"description":"Tool behavior hints snapshot","properties":{"destructiveHint":{"type":"boolean"},"idempotentHint":{"type":"boolean"},"openWorldHint":{"type":"boolean"},"readOnlyHint":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"contracts.ToolCallRecord":{"description":"The new tool call record","properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"arguments":{"description":"Tool arguments","type":"object"},"config_path":{"description":"Active config file path","type":"string"},"duration":{"description":"Duration in nanoseconds","type":"integer"},"error":{"description":"Error message (failure only)","type":"string"},"execution_type":{"description":"\"direct\" or \"code_execution\"","type":"string"},"id":{"description":"Unique identifier","type":"string"},"mcp_client_name":{"description":"MCP client name from InitializeRequest","type":"string"},"mcp_client_version":{"description":"MCP client version","type":"string"},"mcp_session_id":{"description":"MCP session identifier","type":"string"},"metrics":{"$ref":"#/components/schemas/contracts.TokenMetrics"},"parent_call_id":{"description":"Links nested calls to parent code_execution","type":"string"},"request_id":{"description":"Request correlation ID","type":"string"},"response":{"description":"Tool response (success only)","type":"object"},"server_id":{"description":"Server identity hash","type":"string"},"server_name":{"description":"Human-readable server name","type":"string"},"timestamp":{"description":"When the call was made","type":"string"},"tool_name":{"description":"Tool name (without server prefix)","type":"string"}},"type":"object"},"contracts.UpdateInfo":{"description":"Update information (if available)","properties":{"available":{"description":"Whether an update is available","type":"boolean"},"check_error":{"description":"Error message if update check failed","type":"string"},"checked_at":{"description":"When the update check was performed","type":"string"},"is_prerelease":{"description":"Whether the latest version is a prerelease","type":"boolean"},"latest_version":{"description":"Latest version available (e.g., \"v1.2.3\")","type":"string"},"release_url":{"description":"URL to the release page","type":"string"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.UsageAggregateResponse":{"properties":{"freshness_ms":{"description":"age of the underlying snapshot in ms","type":"integer"},"generated_at":{"type":"string"},"other":{"$ref":"#/components/schemas/contracts.UsageOtherBucket"},"timeline":{"items":{"$ref":"#/components/schemas/contracts.UsageTimeBucket"},"type":"array","uniqueItems":false},"token_source":{"description":"\"bytes\" (size-based proxy, FR-006)","type":"string"},"tokens_saved":{"description":"echoed from ServerTokenMetrics (FR-007)","type":"integer"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"window":{"type":"string"}},"type":"object"},"contracts.UsageOtherBucket":{"description":"present only when the list was truncated to top-N","properties":{"calls":{"type":"integer"},"tools_folded":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageTimeBucket":{"properties":{"calls":{"type":"integer"},"errors":{"type":"integer"},"start":{"type":"string"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageToolStat":{"properties":{"avg_req_bytes":{"description":"null when no sized request calls","type":"integer"},"avg_resp_bytes":{"description":"null when sized_calls == 0 (only legacy 0-byte calls)","type":"integer"},"blocked":{"type":"integer"},"calls":{"type":"integer"},"error_rate":{"type":"number"},"errors":{"type":"integer"},"last_used":{"type":"string"},"p50_ms":{"type":"integer"},"p95_ms":{"type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"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"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"reconnect_on_use":{"type":"boolean"},"url":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.CanonicalConfigPath":{"properties":{"description":{"description":"Brief description","type":"string"},"exists":{"description":"Whether the file exists","type":"boolean"},"format":{"description":"Format identifier (e.g., \"claude_desktop\")","type":"string"},"name":{"description":"Display name (e.g., \"Claude Desktop\")","type":"string"},"os":{"description":"Operating system (darwin, windows, linux)","type":"string"},"path":{"description":"Full path to the config file","type":"string"}},"type":"object"},"httpapi.CanonicalConfigPathsResponse":{"properties":{"os":{"description":"Current operating system","type":"string"},"paths":{"description":"List of canonical config paths","items":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPath"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.ImportFromPathRequest":{"properties":{"format":{"description":"Optional format hint","type":"string"},"path":{"description":"File path to import from","type":"string"},"rename":{"additionalProperties":{"type":"string"},"description":"Rename maps a server name → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys are matched against\neither the raw source name (OriginalName) or the sanitized name shown\nin the preview (Server.Name); these differ for names that need\nsanitizing (e.g. \"Figma Desktop\" → \"Figma_Desktop\"). Keys not present\nin the imported set are ignored.","type":"object"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportRequest":{"properties":{"content":{"description":"Raw JSON or TOML content","type":"string"},"format":{"description":"Optional format hint","type":"string"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportResponse":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/configimport.FailedServer"},"type":"array","uniqueItems":false},"format":{"type":"string"},"format_name":{"type":"string"},"imported":{"items":{"$ref":"#/components/schemas/httpapi.ImportedServerResponse"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/configimport.SkippedServer"},"type":"array","uniqueItems":false},"summary":{"$ref":"#/components/schemas/configimport.ImportSummary"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportedServerResponse":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (image,\nnetwork_mode, extra_args, working_dir, enabled). A nil pointer\nmeans \"do not touch isolation config\"; an empty-but-present\nobject on PATCH intentionally clears the overrides.","properties":{"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.OnboardingMarkRequest":{"properties":{"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","type":"string"},"engaged":{"description":"Engaged marks the wizard as engaged (completed or explicitly skipped).\nOnce true, the wizard does not auto-show again.","type":"boolean"},"mark_shown":{"description":"MarkShown records the wizard's first display time if not already set.","type":"boolean"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","type":"string"}},"type":"object"},"httpapi.SetActiveProfileRequest":{"properties":{"active_profile":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned upstream servers (MCP-2769). It is OFF by\ndefault and deliberately kept out of the AllowedSystemVars default list:\nproxy URLs frequently carry credentials (http://user:pass@proxy), so\nforwarding them to every stdio upstream is a credential-leak risk. When\nenabled, values are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"},"telemetry.FeedbackContext":{"properties":{"arch":{"type":"string"},"connected_server_count":{"type":"integer"},"edition":{"type":"string"},"os":{"type":"string"},"routing_mode":{"type":"string"},"server_count":{"type":"integer"},"version":{"type":"string"}},"type":"object"},"telemetry.FeedbackRequest":{"properties":{"category":{"description":"bug, feature, other","type":"string"},"context":{"$ref":"#/components/schemas/telemetry.FeedbackContext"},"email":{"type":"string"},"message":{"type":"string"}},"type":"object"},"telemetry.FeedbackResponse":{"properties":{"error":{"type":"string"},"issue_url":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"description":"API key authentication via query parameter. Use ?apikey=your-key","in":"query","name":"apikey","type":"apiKey"}}},
"info": {"contact":{"name":"MCPProxy Support","url":"https://github.com/smart-mcp-proxy/mcpproxy-go"},"description":"{{escape .Description}}","license":{"name":"MIT","url":"https://opensource.org/licenses/MIT"},"title":"{{.Title}}","version":"{{.Version}}"},
"externalDocs": {"description":"","url":""},
- "paths": {"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to export (1-50000, default 10000)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get usage statistics aggregate","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/config":{"get":{"description":"Retrieves the current MCPProxy configuration including all server definitions, global settings, and runtime parameters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetConfigResponse"}}},"description":"Configuration retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Apply configuration","tags":["config"]}},"/api/v1/config/docker-isolation":{"patch":{"description":"Convenience endpoint to flip ` + "`" + `docker_isolation.enabled` + "`" + ` without resending the full config. Persists to disk via the existing config writer — the file watcher then hot-reloads the change. Returns the new state and whether a restart is required for existing connections to pick it up.","requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"}},"type":"object"}}},"description":"New isolation state","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Isolation toggle applied"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Toggle global Docker isolation","tags":["config"]}},"/api/v1/config/validate":{"post":{"description":"Validates a provided MCPProxy configuration without applying it. Checks for syntax errors, invalid server definitions, conflicting settings, and other configuration issues.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to validate","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ValidateConfigResponse"}}},"description":"Configuration validation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Validation failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Validate configuration","tags":["config"]}},"/api/v1/connect":{"get":{"description":"Returns the connection status for all known MCP client applications.\nEach entry indicates whether the client config file exists and whether\nMCPProxy is currently registered in it.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"List of ClientStatus objects"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List client connection status","tags":["connect"]}},"/api/v1/connect/{client}":{"delete":{"description":"Remove the MCPProxy entry from the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional parameters (server_name)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or entry not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disconnect MCPProxy from a client","tags":["connect"]},"get":{"description":"Resolves one client's status by reading its config file on demand.\nThis is the only Connect endpoint that opens a client config file, so\non macOS it is the sole place an App-Data privacy prompt may legitimately\nappear (scoped to this user action). Resolves access_state to\naccessible|absent|denied|malformed and populates remediation when denied.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get a single client's connection status (on-demand)","tags":["connect"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Already connected (use force=true)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Connect MCPProxy to a client","tags":["connect"]}},"/api/v1/diagnostics":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/docker/status":{"get":{"description":"Retrieve current Docker availability and recovery status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Docker status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get Docker status","tags":["docker"]}},"/api/v1/doctor":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/feedback":{"post":{"description":"Submit a bug report, feature request, or general feedback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackRequest"}}},"description":"Feedback request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Bad Request"},"429":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyAuth":[]}],"summary":"Submit feedback","tags":["feedback"]}},"/api/v1/index/search":{"get":{"description":"Search across all upstream MCP server tools using BM25 keyword search","parameters":[{"description":"Search query","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Maximum number of results","in":"query","name":"limit","schema":{"default":10,"maximum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchToolsResponse"}}},"description":"Search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing query parameter)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search for tools","tags":["tools"]}},"/api/v1/info":{"get":{"description":"Get essential server metadata including version, web UI URL, endpoint addresses, and update availability\nThis endpoint is designed for tray-core communication and version checking\nUse refresh=true query parameter to force an immediate update check against GitHub","parameters":[{"description":"Force immediate update check against GitHub","in":"query","name":"refresh","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Server information with optional update info"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server information","tags":["status"]}},"/api/v1/onboarding/mark":{"post":{"description":"Updates wizard engagement and per-step status. Once engaged is\ntrue, the wizard does not auto-show again, even if state regresses.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.OnboardingMarkRequest"}}},"description":"Mark request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Mark onboarding wizard state (Spec 046)","tags":["onboarding"]}},"/api/v1/onboarding/state":{"get":{"description":"Returns the wizard engagement record alongside live predicates\n(whether any client is connected, whether any server is configured),\nplus a derived ShouldShowWizard flag the frontend can rely on.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"OnboardingStateResponse"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get onboarding wizard state and predicates (Spec 046)","tags":["onboarding"]}},"/api/v1/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":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin | duplicate_registry"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a user-supplied registry source","tags":["registries"]}},"/api/v1/registries/{id}":{"delete":{"description":"Removes a custom/unverified registry previously added via add-source (MCP-1057). Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source removed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove a user-added custom registry source","tags":["registries"]},"put":{"description":"Updates a custom registry previously added via add-source (MCP-1072): name, url, servers-url. Empty fields are left unchanged. Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found; a non-https url yields invalid_registry_url. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.EditRegistrySourceRequest"}}},"description":"Fields to update (name/url/servers_url; empty = unchanged)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required | invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Edit a user-added custom registry source","tags":["registries"]}},"/api/v1/registries/{id}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove an upstream server","tags":["servers"]},"patch":{"description":"Update specific fields of an existing upstream MCP server configuration.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Fields to update (all optional)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server updated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - no fields or invalid body"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/config-to-secret":{"post":{"description":"Atomically reads the real value from the server config, stores it in the OS keyring, and rewrites the config field to ` + "`" + `${keyring:\u003cname\u003e}` + "`" + `. Unblocks the UI's Convert-to-secret affordance for values the API redacts on the read path.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored, config updated with reference"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad scope/key/secret_name, or value is already a reference / empty"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server or key not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver or config update failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Convert a header / env value to a keyring secret","tags":["servers"]}},"/api/v1/servers/{id}/disable":{"post":{"description":"Disable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server disabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/discover-tools":{"post":{"description":"Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool discovery triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to discover tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Discover tools for a specific server","tags":["servers"]}},"/api/v1/servers/{id}/enable":{"post":{"description":"Enable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server enabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/login":{"post":{"description":"Initiate OAuth authentication flow for a specific upstream MCP server. Returns structured OAuth start response with correlation ID for tracking.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthStartResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthFlowError"}}},"description":"OAuth error (client_id required, DCR failed, etc.)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Trigger OAuth login for server","tags":["servers"]}},"/api/v1/servers/{id}/logout":{"post":{"description":"Clear OAuth authentication token and disconnect a specific upstream MCP server. The server will need to re-authenticate before tools can be used again.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"OAuth logout completed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled or read-only mode)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Clear OAuth token and disconnect server","tags":["servers"]}},"/api/v1/servers/{id}/logs":{"get":{"description":"Retrieve log entries for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Number of log lines to retrieve","in":"query","name":"tail","schema":{"default":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerLogsResponse"}}},"description":"Server logs retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server logs","tags":["servers"]}},"/api/v1/servers/{id}/quarantine":{"post":{"description":"Place a specific upstream MCP server in quarantine to prevent tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server quarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Quarantine a server","tags":["servers"]}},"/api/v1/servers/{id}/restart":{"post":{"description":"Restart the connection to a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server restarted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/tool-calls":{"get":{"description":"Retrieves tool call history filtered by upstream server ID. Returns recent tool executions for the specified server including timestamps, arguments, results, and errors. Useful for server-specific debugging and monitoring.","parameters":[{"description":"Upstream server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolCallsResponse"}}},"description":"Server tool calls retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get server tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history for specific server","tags":["tool-calls"]}},"/api/v1/servers/{id}/tools":{"get":{"description":"Retrieve all available tools for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolsResponse"}}},"description":"Server tools retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/block":{"post":{"description":"Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The \"blocked\" field counts tools actually blocked.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Block result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Block (approve+disable) tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/disable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/enable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/unquarantine":{"post":{"description":"Remove a specific upstream MCP server from quarantine to allow tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server unquarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Unquarantine a server","tags":["servers"]}},"/api/v1/sessions":{"get":{"description":"Retrieves paginated list of active and recent MCP client sessions. Each session represents a connection from an MCP client to MCPProxy, tracking initialization time, tool calls, and connection status.","parameters":[{"description":"Maximum number of sessions to return (1-100, default 10)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of sessions to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get sessions"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get active MCP sessions","tags":["sessions"]}},"/api/v1/sessions/{id}":{"get":{"description":"Retrieves detailed information about a specific MCP client session including initialization parameters, connection status, tool call count, and activity timestamps.","parameters":[{"description":"Session ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionDetailResponse"}}},"description":"Session details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get MCP session details by ID","tags":["sessions"]}},"/api/v1/stats/tokens":{"get":{"description":"Retrieve token savings statistics across all servers and sessions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Token statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get token savings statistics","tags":["stats"]}},"/api/v1/status":{"get":{"description":"Get comprehensive server status including running state, listen address, upstream statistics, and timestamp","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server status","tags":["status"]}},"/api/v1/telemetry/payload":{"get":{"description":"Render the exact JSON heartbeat payload that mcpproxy would next send to the telemetry endpoint, without making a network call. Counters in the payload reflect the current in-memory state. Spec 042.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Telemetry heartbeat payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}},
+ "paths": {"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to export (1-50000, default 10000)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get usage statistics aggregate","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/config":{"get":{"description":"Retrieves the current MCPProxy configuration including all server definitions, global settings, and runtime parameters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetConfigResponse"}}},"description":"Configuration retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Apply configuration","tags":["config"]}},"/api/v1/config/docker-isolation":{"patch":{"description":"Convenience endpoint to flip ` + "`" + `docker_isolation.enabled` + "`" + ` without resending the full config. Persists to disk via the existing config writer — the file watcher then hot-reloads the change. Returns the new state and whether a restart is required for existing connections to pick it up.","requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"}},"type":"object"}}},"description":"New isolation state","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Isolation toggle applied"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Toggle global Docker isolation","tags":["config"]}},"/api/v1/config/validate":{"post":{"description":"Validates a provided MCPProxy configuration without applying it. Checks for syntax errors, invalid server definitions, conflicting settings, and other configuration issues.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to validate","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ValidateConfigResponse"}}},"description":"Configuration validation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Validation failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Validate configuration","tags":["config"]}},"/api/v1/connect":{"get":{"description":"Returns the connection status for all known MCP client applications.\nEach entry indicates whether the client config file exists and whether\nMCPProxy is currently registered in it.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"List of ClientStatus objects"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List client connection status","tags":["connect"]}},"/api/v1/connect/{client}":{"delete":{"description":"Remove the MCPProxy entry from the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional parameters (server_name)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or entry not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disconnect MCPProxy from a client","tags":["connect"]},"get":{"description":"Resolves one client's status by reading its config file on demand.\nThis is the only Connect endpoint that opens a client config file, so\non macOS it is the sole place an App-Data privacy prompt may legitimately\nappear (scoped to this user action). Resolves access_state to\naccessible|absent|denied|malformed and populates remediation when denied.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get a single client's connection status (on-demand)","tags":["connect"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Already connected (use force=true)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Connect MCPProxy to a client","tags":["connect"]}},"/api/v1/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.","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":"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/diagnostics":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/docker/status":{"get":{"description":"Retrieve current Docker availability and recovery status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Docker status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get Docker status","tags":["docker"]}},"/api/v1/doctor":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/feedback":{"post":{"description":"Submit a bug report, feature request, or general feedback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackRequest"}}},"description":"Feedback request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Bad Request"},"429":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyAuth":[]}],"summary":"Submit feedback","tags":["feedback"]}},"/api/v1/index/search":{"get":{"description":"Search across all upstream MCP server tools using BM25 keyword search","parameters":[{"description":"Search query","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Maximum number of results","in":"query","name":"limit","schema":{"default":10,"maximum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchToolsResponse"}}},"description":"Search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing query parameter)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search for tools","tags":["tools"]}},"/api/v1/info":{"get":{"description":"Get essential server metadata including version, web UI URL, endpoint addresses, and update availability\nThis endpoint is designed for tray-core communication and version checking\nUse refresh=true query parameter to force an immediate update check against GitHub","parameters":[{"description":"Force immediate update check against GitHub","in":"query","name":"refresh","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Server information with optional update info"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server information","tags":["status"]}},"/api/v1/onboarding/mark":{"post":{"description":"Updates wizard engagement and per-step status. Once engaged is\ntrue, the wizard does not auto-show again, even if state regresses.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.OnboardingMarkRequest"}}},"description":"Mark request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Mark onboarding wizard state (Spec 046)","tags":["onboarding"]}},"/api/v1/onboarding/state":{"get":{"description":"Returns the wizard engagement record alongside live predicates\n(whether any client is connected, whether any server is configured),\nplus a derived ShouldShowWizard flag the frontend can rely on.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"OnboardingStateResponse"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get onboarding wizard state and predicates (Spec 046)","tags":["onboarding"]}},"/api/v1/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":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin | duplicate_registry"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a user-supplied registry source","tags":["registries"]}},"/api/v1/registries/{id}":{"delete":{"description":"Removes a custom/unverified registry previously added via add-source (MCP-1057). Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source removed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove a user-added custom registry source","tags":["registries"]},"put":{"description":"Updates a custom registry previously added via add-source (MCP-1072): name, url, servers-url. Empty fields are left unchanged. Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found; a non-https url yields invalid_registry_url. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.EditRegistrySourceRequest"}}},"description":"Fields to update (name/url/servers_url; empty = unchanged)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required | invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Edit a user-added custom registry source","tags":["registries"]}},"/api/v1/registries/{id}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove an upstream server","tags":["servers"]},"patch":{"description":"Update specific fields of an existing upstream MCP server configuration.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Fields to update (all optional)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server updated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - no fields or invalid body"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/config-to-secret":{"post":{"description":"Atomically reads the real value from the server config, stores it in the OS keyring, and rewrites the config field to ` + "`" + `${keyring:\u003cname\u003e}` + "`" + `. Unblocks the UI's Convert-to-secret affordance for values the API redacts on the read path.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored, config updated with reference"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad scope/key/secret_name, or value is already a reference / empty"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server or key not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver or config update failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Convert a header / env value to a keyring secret","tags":["servers"]}},"/api/v1/servers/{id}/disable":{"post":{"description":"Disable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server disabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/discover-tools":{"post":{"description":"Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool discovery triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to discover tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Discover tools for a specific server","tags":["servers"]}},"/api/v1/servers/{id}/enable":{"post":{"description":"Enable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server enabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/login":{"post":{"description":"Initiate OAuth authentication flow for a specific upstream MCP server. Returns structured OAuth start response with correlation ID for tracking.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthStartResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthFlowError"}}},"description":"OAuth error (client_id required, DCR failed, etc.)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Trigger OAuth login for server","tags":["servers"]}},"/api/v1/servers/{id}/logout":{"post":{"description":"Clear OAuth authentication token and disconnect a specific upstream MCP server. The server will need to re-authenticate before tools can be used again.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"OAuth logout completed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled or read-only mode)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Clear OAuth token and disconnect server","tags":["servers"]}},"/api/v1/servers/{id}/logs":{"get":{"description":"Retrieve log entries for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Number of log lines to retrieve","in":"query","name":"tail","schema":{"default":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerLogsResponse"}}},"description":"Server logs retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server logs","tags":["servers"]}},"/api/v1/servers/{id}/quarantine":{"post":{"description":"Place a specific upstream MCP server in quarantine to prevent tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server quarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Quarantine a server","tags":["servers"]}},"/api/v1/servers/{id}/restart":{"post":{"description":"Restart the connection to a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server restarted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/tool-calls":{"get":{"description":"Retrieves tool call history filtered by upstream server ID. Returns recent tool executions for the specified server including timestamps, arguments, results, and errors. Useful for server-specific debugging and monitoring.","parameters":[{"description":"Upstream server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolCallsResponse"}}},"description":"Server tool calls retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get server tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history for specific server","tags":["tool-calls"]}},"/api/v1/servers/{id}/tools":{"get":{"description":"Retrieve all available tools for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolsResponse"}}},"description":"Server tools retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/block":{"post":{"description":"Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The \"blocked\" field counts tools actually blocked.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Block result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Block (approve+disable) tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/disable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/enable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/unquarantine":{"post":{"description":"Remove a specific upstream MCP server from quarantine to allow tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server unquarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Unquarantine a server","tags":["servers"]}},"/api/v1/sessions":{"get":{"description":"Retrieves paginated list of active and recent MCP client sessions. Each session represents a connection from an MCP client to MCPProxy, tracking initialization time, tool calls, and connection status.","parameters":[{"description":"Maximum number of sessions to return (1-100, default 10)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of sessions to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get sessions"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get active MCP sessions","tags":["sessions"]}},"/api/v1/sessions/{id}":{"get":{"description":"Retrieves detailed information about a specific MCP client session including initialization parameters, connection status, tool call count, and activity timestamps.","parameters":[{"description":"Session ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionDetailResponse"}}},"description":"Session details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get MCP session details by ID","tags":["sessions"]}},"/api/v1/stats/tokens":{"get":{"description":"Retrieve token savings statistics across all servers and sessions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Token statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get token savings statistics","tags":["stats"]}},"/api/v1/status":{"get":{"description":"Get comprehensive server status including running state, listen address, upstream statistics, and timestamp","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server status","tags":["status"]}},"/api/v1/telemetry/payload":{"get":{"description":"Render the exact JSON heartbeat payload that mcpproxy would next send to the telemetry endpoint, without making a network call. Counters in the payload reflect the current in-memory state. Spec 042.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Telemetry heartbeat payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}},
"openapi": "3.1.0"
}`
diff --git a/oas/swagger.yaml b/oas/swagger.yaml
index d68b2c67d..f0e8ebc1f 100644
--- a/oas/swagger.yaml
+++ b/oas/swagger.yaml
@@ -3594,6 +3594,55 @@ paths:
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
+ config — target path, server key, entry name, and entry contents — WITHOUT
+ modifying the file or creating a backup (Spec 078 US1). The embedded API key
+ is masked in the payload; contains_api_key flags that a credential is written.
+ entry_exists distinguishes a create from an overwrite of a same-named entry.
+ Reads the config on demand to classify create-vs-overwrite, so on macOS this
+ may raise an App-Data privacy prompt; a denial returns 403 + remediation.
+ 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: 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/diagnostics:
get:
description: Get comprehensive health diagnostics including upstream errors,
From 6aa95518777d204f41da4f83914847df14eb9a34 Mon Sep 17 00:00:00 2001
From: Algis Dumbris
Date: Thu, 2 Jul 2026 17:12:20 +0300
Subject: [PATCH 2/7] feat(web): preview the exact change before writing in
wizard + Connect modal
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Second slice of specs/078-connect-trust-preview US1 (FR-001,003,004): the
Connect action in BOTH the standalone Connect modal and the onboarding
wizard's ClientRow now fetches the preview first and shows an inline panel
before any file is written:
- target config path and, in a monospace additive ("+ will be added")
block, the exact entry that will be written (pretty JSON, or TOML for
Codex) with the API key masked;
- plain-language trust copy ("Only this entry is added — everything else in
the file stays untouched. A timestamped backup is created first.");
- a masked-key line when contains_api_key so the user knows a credential is
written;
- an overwrite warning when an entry with the same name already exists, in
which case confirming implies force=true;
- graceful copy for the bridge/no-prior-file (access_state=absent) and
unparseable (malformed) cases.
Confirm proceeds with the connect (reusing the existing #799 post-connect
backup-path line, untouched); Cancel dismisses the panel and writes nothing.
A non-denial preview fetch error is surfaced inline; in the modal a denied
read still resolves the Spec 075 access banner via checkAccess. Wizard
connect-step telemetry (markConnectCompleted/Skipped) is unchanged.
- types/api.ts: ConnectPreview; services/api.ts: getConnectPreview
- ConnectModal.vue + OnboardingWizard.vue ClientRow: preview panel + flow
- The existing #799 connect specs are updated to drive click → preview →
confirm (Connect no longer writes on the first click); new
connect-preview.spec.ts covers preview render, cancel-writes-nothing,
confirm-proceeds, masked key, and overwrite→force for both surfaces.
Full frontend suite: 33 files, 244 tests pass; vue-tsc clean; make build
embeds the updated dist. npm-ci lockfile churn reverted (as in #799).
Related #793
Co-Authored-By: Claude Fable 5
---
frontend/src/components/ConnectModal.vue | 153 ++++++++++-
frontend/src/components/OnboardingWizard.vue | 197 ++++++++++++++-
frontend/src/services/api.ts | 11 +-
frontend/src/types/api.ts | 18 ++
frontend/tests/unit/connect-modal.spec.ts | 35 ++-
frontend/tests/unit/connect-preview.spec.ts | 238 ++++++++++++++++++
.../onboarding-wizard-backup-path.spec.ts | 46 +++-
7 files changed, 665 insertions(+), 33 deletions(-)
create mode 100644 frontend/tests/unit/connect-preview.spec.ts
diff --git a/frontend/src/components/ConnectModal.vue b/frontend/src/components/ConnectModal.vue
index 2e0d38367..cff77861d 100644
--- a/frontend/src/components/ConnectModal.vue
+++ b/frontend/src/components/ConnectModal.vue
@@ -69,11 +69,12 @@
+
+
+ Only this entry is added to
+ {{ previews[client.id]!.config_path }}.
+ Everything else in the file stays untouched, and a timestamped backup is created first.
+
+
+
+ An entry named “{{ previews[client.id]!.server_name }}” already exists — connecting will overwrite it (a backup is saved first).
+
+
+
+ Your current config could not be parsed, so we can't show whether an entry already exists. Connecting still writes only the “{{ previews[client.id]!.server_name }}” entry.
+
+
+
+ This file will be created; there is no prior file to back up.
+
+
+
+ will be added
+
{{ previews[client.id]!.entry_text }}
+
+
+
+ The URL includes your API key (shown masked). The real key is written into the config so the client can authenticate.
+
+
+
+
+
+
+
+
+ {{ previewError[client.id] }}
+
@@ -210,7 +292,7 @@ import { ref, reactive, computed, watch } from 'vue'
import api from '@/services/api'
import { useSystemStore } from '@/stores/system'
import { useOnboardingStore } from '@/stores/onboarding'
-import type { ClientStatus, AccessState } from '@/types'
+import type { ClientStatus, AccessState, ConnectPreview } from '@/types'
interface Props {
show: boolean
@@ -251,6 +333,13 @@ const loading = reactive({
const resolved = ref>({})
const checking = reactive>({})
const copiedClient = ref(null)
+// Spec 078 US1: a fetched preview per client (the exact change a connect would
+// make, no write yet). Present => the confirm/cancel panel is shown for that
+// client. previewError holds a non-denial fetch failure (a denial resolves to
+// the access-state banner instead).
+const previews = ref>({})
+const previewLoading = reactive>({})
+const previewError = ref>({})
// MCP-2952: `GET /api/v1/connect` is stat-only (#706/MCP-2829) and reports
// connected=false for every client. Merge the content-resolved
@@ -326,24 +415,64 @@ async function fetchClients() {
}
}
-// Single-connect entry point (row button): a fresh single operation supersedes
-// any earlier Connect All per-client backup list.
-async function connectSingle(clientId: string) {
+// Spec 078 US1: clicking the row's Connect fetches the preview first and shows
+// the confirm/cancel panel — no file is written until the user confirms. A
+// denied read (403) is surfaced as the access-state banner via checkAccess.
+async function startConnect(clientId: string) {
+ previewLoading[clientId] = true
+ previewError.value = { ...previewError.value, [clientId]: '' }
+ try {
+ const response = await api.getConnectPreview(clientId)
+ if (response.success && response.data) {
+ previews.value = { ...previews.value, [clientId]: response.data }
+ } else {
+ // The preview read may have been blocked by macOS App-Data — resolve the
+ // access state so a denial renders the existing remediation banner.
+ previewError.value = { ...previewError.value, [clientId]: response.error || 'Failed to load preview' }
+ void checkAccess(clientId)
+ }
+ } catch (err) {
+ previewError.value = { ...previewError.value, [clientId]: err instanceof Error ? err.message : 'Failed to load preview' }
+ void checkAccess(clientId)
+ } finally {
+ previewLoading[clientId] = false
+ }
+}
+
+// Cancel dismisses the preview WITHOUT writing anything (Spec 078 US1).
+function cancelPreview(clientId: string) {
+ const next = { ...previews.value }
+ delete next[clientId]
+ previews.value = next
+}
+
+function clearPreview(clientId: string) {
+ cancelPreview(clientId)
+ const nextErr = { ...previewError.value }
+ delete nextErr[clientId]
+ previewError.value = nextErr
+}
+
+// Confirm proceeds with the connect. If an entry already exists, confirming
+// implies force=true (the user saw the overwrite warning in the preview).
+async function confirmConnect(clientId: string) {
+ const force = previews.value[clientId]?.entry_exists === true
bulkBackups.value = []
copiedBulkClient.value = null
- await connect(clientId)
+ await connect(clientId, force)
+ clearPreview(clientId)
}
// Returns the outcome so connectAll can accumulate per-client backup results
// (ok=true with backupPath string = backup created; null = no prior file).
-async function connect(clientId: string): Promise<{ ok: boolean; backupPath: string | null }> {
+async function connect(clientId: string, force = false): Promise<{ ok: boolean; backupPath: string | null }> {
loading.clients[clientId] = true
resultMessage.value = ''
resultBackupPath.value = undefined
copiedBackup.value = false
try {
- const response = await api.connectClient(clientId)
+ const response = await api.connectClient(clientId, 'mcpproxy', force)
if (response.success && response.data) {
resultMessage.value = response.data.message || `Connected to ${clientId}`
resultSuccess.value = true
@@ -525,6 +654,8 @@ function close() {
copiedBackup.value = false
bulkBackups.value = []
copiedBulkClient.value = null
+ previews.value = {}
+ previewError.value = {}
emit('close')
}
@@ -540,6 +671,8 @@ watch(() => props.show, (newVal) => {
copiedBackup.value = false
bulkBackups.value = []
copiedBulkClient.value = null
+ previews.value = {}
+ previewError.value = {}
}
})
diff --git a/frontend/src/components/OnboardingWizard.vue b/frontend/src/components/OnboardingWizard.vue
index b778119c0..11744f67a 100644
--- a/frontend/src/components/OnboardingWizard.vue
+++ b/frontend/src/components/OnboardingWizard.vue
@@ -70,8 +70,13 @@
:busy="busyClients[c.id]"
:backup-path="connectBackups[c.id]"
:copied-backup="copiedBackupClient === c.id"
+ :preview="previews[c.id]"
+ :preview-busy="previewBusy[c.id]"
+ :preview-error="previewErrors[c.id]"
@copy-backup="copyBackupPath"
- @connect="connectOne"
+ @connect="startConnect"
+ @confirm="confirmConnect"
+ @cancel="cancelPreview"
/>
@@ -85,8 +90,13 @@
:busy="busyClients[c.id]"
:backup-path="connectBackups[c.id]"
:copied-backup="copiedBackupClient === c.id"
+ :preview="previews[c.id]"
+ :preview-busy="previewBusy[c.id]"
+ :preview-error="previewErrors[c.id]"
@copy-backup="copyBackupPath"
- @connect="connectOne"
+ @connect="startConnect"
+ @confirm="confirmConnect"
+ @cancel="cancelPreview"
/>
@@ -104,8 +114,13 @@
:busy="busyClients[c.id]"
:backup-path="connectBackups[c.id]"
:copied-backup="copiedBackupClient === c.id"
+ :preview="previews[c.id]"
+ :preview-busy="previewBusy[c.id]"
+ :preview-error="previewErrors[c.id]"
@copy-backup="copyBackupPath"
- @connect="connectOne"
+ @connect="startConnect"
+ @confirm="confirmConnect"
+ @cancel="cancelPreview"
/>
@@ -501,7 +516,7 @@ import { useOnboardingStore } from '@/stores/onboarding'
import { useSystemStore } from '@/stores/system'
import { useServersStore } from '@/stores/servers'
import AddServerModal from '@/components/AddServerModal.vue'
-import type { ClientStatus, ActivityRecord } from '@/types'
+import type { ClientStatus, ActivityRecord, ConnectPreview } from '@/types'
interface Props {
show: boolean
@@ -532,6 +547,12 @@ const connectMessageOk = ref(true)
// prior file existed (nothing to back up); absent = no connect happened yet.
const connectBackups = reactive>({})
const copiedBackupClient = ref(null)
+// Spec 078 US1: preview the exact change before writing. previews[id] present
+// => the confirm/cancel panel is shown in that client's row; the write only
+// happens on confirm. previewErrors holds a non-denial fetch failure.
+const previews = reactive>({})
+const previewBusy = reactive>({})
+const previewErrors = reactive>({})
const addServerOpen = ref(false)
const serverAddedJustNow = ref(false)
@@ -711,6 +732,10 @@ watch(() => props.show, async (open) => {
// rows from connects performed in a previous wizard session.
for (const k of Object.keys(connectBackups)) delete connectBackups[k]
copiedBackupClient.value = null
+ // Spec 078 US1: previews are session-scoped too — don't replay a stale
+ // confirm/cancel panel from a previous wizard session.
+ for (const k of Object.keys(previews)) delete previews[k]
+ for (const k of Object.keys(previewErrors)) delete previewErrors[k]
await onboarding.fetchState()
await Promise.all([
fetchClients(),
@@ -1038,11 +1063,47 @@ function onToggleQuarantine(v: boolean) {
void patchConfig({ quarantine_enabled: v })
}
-async function connectOne(clientId: string) {
+// Spec 078 US1: the row's Connect fetches the preview first and opens the
+// confirm/cancel panel — nothing is written until confirm. A denied read is
+// surfaced as an error message (the wizard has no separate access banner yet;
+// the standalone Connect modal carries the full Spec 075 remediation surface).
+async function startConnect(clientId: string) {
+ previewBusy[clientId] = true
+ delete previewErrors[clientId]
+ try {
+ const res = await api.getConnectPreview(clientId)
+ if (res.success && res.data) {
+ previews[clientId] = res.data
+ } else {
+ previewErrors[clientId] = res.error || 'Failed to load preview'
+ }
+ } catch (err) {
+ previewErrors[clientId] = (err as Error).message
+ } finally {
+ previewBusy[clientId] = false
+ }
+}
+
+// Cancel dismisses the preview WITHOUT writing anything.
+function cancelPreview(clientId: string) {
+ delete previews[clientId]
+ delete previewErrors[clientId]
+}
+
+// Confirm proceeds; an existing same-named entry implies force=true (the user
+// saw the overwrite warning in the preview).
+async function confirmConnect(clientId: string) {
+ const force = previews[clientId]?.entry_exists === true
+ delete previews[clientId]
+ delete previewErrors[clientId]
+ await connectOne(clientId, force)
+}
+
+async function connectOne(clientId: string, force = false) {
busyClients[clientId] = true
connectMessage.value = ''
try {
- const res = await api.connectClient(clientId)
+ const res = await api.connectClient(clientId, 'mcpproxy', force)
if (res.success && res.data) {
connectMessageOk.value = true
connectMessage.value = res.data.message || `Connected ${clientId}`
@@ -1149,8 +1210,21 @@ onMounted(() => {
// Inlined as a functional component to keep this file self-contained while
// the row layout stays consistent across all three lists.
const ClientRow: FunctionalComponent<
- { client: ClientStatus; busy?: boolean; backupPath?: string | null; copiedBackup?: boolean },
- { connect: (id: string) => void; 'copy-backup': (id: string) => void }
+ {
+ client: ClientStatus
+ busy?: boolean
+ backupPath?: string | null
+ copiedBackup?: boolean
+ preview?: ConnectPreview
+ previewBusy?: boolean
+ previewError?: string
+ },
+ {
+ connect: (id: string) => void
+ 'copy-backup': (id: string) => void
+ confirm: (id: string) => void
+ cancel: (id: string) => void
+ }
> = (props, { emit: rowEmit }) => {
const c = props.client
const row = h(
@@ -1176,11 +1250,11 @@ const ClientRow: FunctionalComponent<
'button',
{
class: 'btn btn-primary btn-xs',
- disabled: props.busy,
+ disabled: props.busy || props.previewBusy,
'data-test': `connect-${c.id}`,
onClick: () => rowEmit('connect', c.id),
},
- props.busy
+ props.busy || props.previewBusy
? [h('span', { class: 'loading loading-spinner loading-xs' })]
: ['Connect']
),
@@ -1218,13 +1292,109 @@ const ClientRow: FunctionalComponent<
: 'No prior config file existed, so no backup was needed.'
)
: null
+
+ // Spec 078 US1 / FR-001,003,004: preview the exact change before writing.
+ // Only this entry is added; everything else in the file is untouched. The
+ // Connect/Cancel buttons gate the actual write (Cancel writes nothing).
+ const p = props.preview
+ const previewPanel = p
+ ? h(
+ 'div',
+ {
+ class: 'mt-2 rounded-lg bg-base-200/60 border border-base-300 px-3 py-2 space-y-2',
+ 'data-test': `client-preview-${c.id}`,
+ },
+ [
+ h('p', { class: 'text-xs opacity-70 leading-relaxed' }, [
+ 'Only this entry is added to ',
+ h('code', { class: 'font-mono text-[11px] break-all', title: p.config_path }, p.config_path),
+ '. Everything else in the file stays untouched, and a timestamped backup is created first.',
+ ]),
+ p.entry_exists
+ ? h(
+ 'p',
+ { class: 'text-xs text-warning leading-relaxed', 'data-test': `client-preview-overwrite-${c.id}` },
+ `An entry named "${p.server_name}" already exists — connecting will overwrite it (a backup is saved first).`
+ )
+ : p.access_state === 'malformed'
+ ? h(
+ 'p',
+ { class: 'text-xs text-warning leading-relaxed', 'data-test': `client-preview-malformed-${c.id}` },
+ `Your current config could not be parsed, so we can't show whether an entry already exists. Connecting still writes only the "${p.server_name}" entry.`
+ )
+ : p.access_state === 'absent'
+ ? h(
+ 'p',
+ { class: 'text-xs opacity-60 leading-relaxed', 'data-test': `client-preview-no-file-${c.id}` },
+ 'This file will be created; there is no prior file to back up.'
+ )
+ : null,
+ h('div', {}, [
+ h('div', { class: 'text-[11px] font-semibold uppercase tracking-wider text-success/80 mb-1' }, '+ will be added'),
+ h(
+ 'pre',
+ {
+ class: 'text-[11px] font-mono whitespace-pre-wrap break-all rounded bg-base-300/60 border-l-2 border-success px-2 py-1.5 leading-relaxed',
+ 'data-test': `client-preview-entry-${c.id}`,
+ },
+ p.entry_text
+ ),
+ ]),
+ p.contains_api_key
+ ? h(
+ 'p',
+ { class: 'text-[11px] opacity-60 leading-relaxed', 'data-test': `client-preview-apikey-${c.id}` },
+ 'The URL includes your API key (shown masked). The real key is written into the config so the client can authenticate.'
+ )
+ : null,
+ h('div', { class: 'flex items-center gap-2 pt-0.5' }, [
+ h(
+ 'button',
+ {
+ class: 'btn btn-primary btn-xs',
+ disabled: props.busy,
+ 'data-test': `client-preview-confirm-${c.id}`,
+ onClick: () => rowEmit('confirm', c.id),
+ },
+ props.busy ? [h('span', { class: 'loading loading-spinner loading-xs' })] : ['Connect']
+ ),
+ h(
+ 'button',
+ {
+ class: 'btn btn-ghost btn-xs',
+ disabled: props.busy,
+ 'data-test': `client-preview-cancel-${c.id}`,
+ onClick: () => rowEmit('cancel', c.id),
+ },
+ 'Cancel'
+ ),
+ ]),
+ ]
+ )
+ : null
+
+ // Non-denial preview fetch failure (the wizard has no separate access banner;
+ // the standalone Connect modal carries the full Spec 075 remediation surface).
+ const previewErrorLine =
+ !p && props.previewError
+ ? h(
+ 'p',
+ { class: 'mt-2 text-xs text-error', 'data-test': `client-preview-error-${c.id}` },
+ props.previewError
+ )
+ : null
+
+ const children = [row]
+ if (backupLine) children.push(backupLine)
+ if (previewPanel) children.push(previewPanel)
+ if (previewErrorLine) children.push(previewErrorLine)
return h(
'div',
{
class: 'p-2 rounded-lg border border-base-300',
'data-test': `client-row-${c.id}`,
},
- backupLine ? [row, backupLine] : [row]
+ children
)
}
ClientRow.props = {
@@ -1232,6 +1402,9 @@ ClientRow.props = {
busy: { type: Boolean, default: false },
backupPath: { type: String, default: undefined },
copiedBackup: { type: Boolean, default: false },
+ preview: { type: Object, default: undefined },
+ previewBusy: { type: Boolean, default: false },
+ previewError: { type: String, default: undefined },
}
-ClientRow.emits = ['connect', 'copy-backup']
+ClientRow.emits = ['connect', 'copy-backup', 'confirm', 'cancel']
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 2dc6b92d3..bc8ad2b3c 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -1,4 +1,4 @@
-import type { APIResponse, Server, Tool, ToolApproval, SearchResult, StatusUpdate, SecretRef, MigrationAnalysis, ConfigSecretsResponse, GetToolCallsResponse, GetToolCallDetailResponse, GetServerToolCallsResponse, GetConfigResponse, ValidateConfigResponse, ConfigApplyResult, ServerTokenMetrics, GetRegistriesResponse, SearchRegistryServersResponse, RegistrySummary, GetSessionsResponse, GetSessionDetailResponse, InfoResponse, ActivityListResponse, ActivityDetailResponse, ActivitySummaryResponse, ImportResponse, AgentTokenInfo, CreateAgentTokenRequest, CreateAgentTokenResponse, RoutingInfo, ConnectStatusResponse, ClientStatus, ConnectResult, OnboardingStateResponse, OnboardingMarkRequest, DiagnosticFixResponse, GlobalToolsResponse, UsageAggregateResponse, UsageWindow, UsageSort, UsageStatus, ListProfilesResponse, ActiveProfileResponse } from '@/types'
+import type { APIResponse, Server, Tool, ToolApproval, SearchResult, StatusUpdate, SecretRef, MigrationAnalysis, ConfigSecretsResponse, GetToolCallsResponse, GetToolCallDetailResponse, GetServerToolCallsResponse, GetConfigResponse, ValidateConfigResponse, ConfigApplyResult, ServerTokenMetrics, GetRegistriesResponse, SearchRegistryServersResponse, RegistrySummary, GetSessionsResponse, GetSessionDetailResponse, InfoResponse, ActivityListResponse, ActivityDetailResponse, ActivitySummaryResponse, ImportResponse, AgentTokenInfo, CreateAgentTokenRequest, CreateAgentTokenResponse, RoutingInfo, ConnectStatusResponse, ClientStatus, ConnectResult, ConnectPreview, OnboardingStateResponse, OnboardingMarkRequest, DiagnosticFixResponse, GlobalToolsResponse, UsageAggregateResponse, UsageWindow, UsageSort, UsageStatus, ListProfilesResponse, ActiveProfileResponse } from '@/types'
// Event types for API service
export interface APIAuthEvent {
@@ -1172,6 +1172,15 @@ class APIService {
return this.request(`/api/v1/connect/${encodeURIComponent(clientId)}`)
}
+ // Spec 078 US1: preview the exact entry a connect would write, WITHOUT
+ // modifying the file or creating a backup. The apikey in the returned entry is
+ // masked. Like getConnectClientStatus this reads the config on demand (to
+ // classify create-vs-overwrite), so on macOS it may raise an App-Data prompt;
+ // a denial returns 403 with remediation (surfaced as success:false + error).
+ async getConnectPreview(clientId: string): Promise> {
+ return this.request(`/api/v1/connect/${encodeURIComponent(clientId)}/preview`)
+ }
+
async connectClient(clientId: string, serverName = 'mcpproxy', force = false): Promise> {
return this.request(`/api/v1/connect/${encodeURIComponent(clientId)}`, {
method: 'POST',
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 423729d01..cf65829bc 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -833,6 +833,24 @@ export interface ConnectResult {
error?: string
}
+// Spec 078 US1: the exact change a connect would make, returned WITHOUT writing
+// the file or creating a backup. entry/entry_text carry a MASKED apikey;
+// contains_api_key flags that a credential is written; entry_exists marks the
+// overwrite (force) case; access_state degrades per Spec 075.
+export interface ConnectPreview {
+ client: string
+ config_path: string
+ format: 'json' | 'toml'
+ server_key: string
+ server_name: string
+ entry: Record
+ entry_text: string
+ entry_exists: boolean
+ contains_api_key: boolean
+ bridge?: boolean
+ access_state?: AccessState
+}
+
// Onboarding wizard types (Spec 046)
export interface OnboardingState {
engaged: boolean
diff --git a/frontend/tests/unit/connect-modal.spec.ts b/frontend/tests/unit/connect-modal.spec.ts
index 8ba0cd29d..74249cfee 100644
--- a/frontend/tests/unit/connect-modal.spec.ts
+++ b/frontend/tests/unit/connect-modal.spec.ts
@@ -8,12 +8,34 @@ vi.mock('@/services/api', () => ({
default: {
getConnectStatus: vi.fn(),
getConnectClientStatus: vi.fn(),
+ getConnectPreview: vi.fn(),
connectClient: vi.fn(),
disconnectClient: vi.fn(),
getOnboardingState: vi.fn(),
},
}))
+// Spec 078 US1: a generic accessible preview (create case). Individual tests
+// override entry_exists / access_state as needed.
+function previewOk(overrides: Record = {}) {
+ return {
+ success: true,
+ data: {
+ client: 'cursor',
+ config_path: '/Users/test/.cursor/mcp.json',
+ format: 'json',
+ server_key: 'mcpServers',
+ server_name: 'mcpproxy',
+ entry: { type: 'http', url: 'http://127.0.0.1:8080/mcp' },
+ entry_text: '{\n "mcpproxy": {\n "type": "http",\n "url": "http://127.0.0.1:8080/mcp"\n }\n}',
+ entry_exists: false,
+ contains_api_key: false,
+ access_state: 'accessible',
+ ...overrides,
+ },
+ }
+}
+
describe('ConnectModal', () => {
let pinia: any
@@ -22,12 +44,15 @@ describe('ConnectModal', () => {
setActivePinia(pinia)
;(api.getConnectStatus as any).mockReset()
;(api.getConnectClientStatus as any).mockReset()
+ ;(api.getConnectPreview as any).mockReset()
;(api.connectClient as any).mockReset()
;(api.disconnectClient as any).mockReset()
;(api.getOnboardingState as any).mockReset()
// Default: no content-resolved connections (most tests exercise the
// stat-only listing only). Individual tests override as needed.
;(api.getOnboardingState as any).mockResolvedValue({ success: true, data: null })
+ // Spec 078 US1: Connect now previews first; default to an accessible create.
+ ;(api.getConnectPreview as any).mockResolvedValue(previewOk())
})
it('renders an OpenCode row', async () => {
@@ -408,7 +433,10 @@ describe('ConnectModal', () => {
await wrapper.setProps({ show: true })
await flushPromises()
- await wrapper.find('button.btn-primary.btn-xs').trigger('click')
+ // Spec 078 US1: click Connect → preview panel → confirm writes.
+ await wrapper.find('[data-test="connect-start-cursor"]').trigger('click')
+ await flushPromises()
+ await wrapper.find('[data-test="connect-preview-confirm-cursor"]').trigger('click')
await flushPromises()
const backup = wrapper.find('[data-test="connect-backup-path"]')
@@ -462,7 +490,10 @@ describe('ConnectModal', () => {
await wrapper.setProps({ show: true })
await flushPromises()
- await wrapper.find('button.btn-primary.btn-xs').trigger('click')
+ // Spec 078 US1: preview then confirm for the bridge (no-prior-file) case.
+ await wrapper.find('[data-test="connect-start-claude-desktop"]').trigger('click')
+ await flushPromises()
+ await wrapper.find('[data-test="connect-preview-confirm-claude-desktop"]').trigger('click')
await flushPromises()
const noBackup = wrapper.find('[data-test="connect-no-backup"]')
diff --git a/frontend/tests/unit/connect-preview.spec.ts b/frontend/tests/unit/connect-preview.spec.ts
new file mode 100644
index 000000000..d636e67d2
--- /dev/null
+++ b/frontend/tests/unit/connect-preview.spec.ts
@@ -0,0 +1,238 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import ConnectModal from '@/components/ConnectModal.vue'
+import OnboardingWizard from '@/components/OnboardingWizard.vue'
+import api from '@/services/api'
+
+// Spec 078 US1: clicking Connect must first show the exact change (target file,
+// entry contents, masked API key, create-vs-overwrite) and only write the file
+// when the user confirms. Cancel writes nothing.
+
+vi.mock('@/services/api', () => ({
+ default: {
+ getConnectStatus: vi.fn(),
+ getConnectClientStatus: vi.fn(),
+ getConnectPreview: vi.fn(),
+ connectClient: vi.fn(),
+ disconnectClient: vi.fn(),
+ getOnboardingState: vi.fn(),
+ // Wizard lifecycle deps:
+ markOnboardingState: vi.fn(),
+ getActivities: vi.fn(),
+ getConfig: vi.fn(),
+ getDockerStatus: vi.fn(),
+ getCanonicalConfigPaths: vi.fn(),
+ },
+}))
+
+function cursorStatus() {
+ return {
+ id: 'cursor',
+ name: 'Cursor',
+ config_path: '/Users/test/.cursor/mcp.json',
+ exists: true,
+ connected: false,
+ supported: true,
+ icon: 'cursor',
+ }
+}
+
+function previewFor(overrides: Record = {}) {
+ return {
+ success: true,
+ data: {
+ client: 'cursor',
+ config_path: '/Users/test/.cursor/mcp.json',
+ format: 'json',
+ server_key: 'mcpServers',
+ server_name: 'mcpproxy',
+ entry: { type: 'sse', url: 'http://127.0.0.1:8080/mcp?apikey=••••' },
+ entry_text:
+ '{\n "mcpproxy": {\n "url": "http://127.0.0.1:8080/mcp?apikey=••••",\n "type": "sse"\n }\n}',
+ entry_exists: false,
+ contains_api_key: true,
+ access_state: 'accessible',
+ ...overrides,
+ },
+ }
+}
+
+function connectResult() {
+ return {
+ success: true,
+ data: {
+ success: true,
+ client: 'cursor',
+ config_path: '/Users/test/.cursor/mcp.json',
+ backup_path: '/Users/test/.cursor/mcp.json.bak.20260702-101530',
+ server_name: 'mcpproxy',
+ action: 'updated',
+ message: 'MCPProxy registered in Cursor as mcpproxy',
+ },
+ }
+}
+
+describe('ConnectModal preview (Spec 078 US1)', () => {
+ let pinia: any
+
+ beforeEach(() => {
+ pinia = createPinia()
+ setActivePinia(pinia)
+ vi.clearAllMocks()
+ ;(api.getConnectStatus as any).mockResolvedValue({ success: true, data: [cursorStatus()] })
+ ;(api.getOnboardingState as any).mockResolvedValue({ success: true, data: null })
+ ;(api.getConnectPreview as any).mockResolvedValue(previewFor())
+ ;(api.connectClient as any).mockResolvedValue(connectResult())
+ })
+
+ async function open() {
+ const wrapper = mount(ConnectModal, { props: { show: false }, global: { plugins: [pinia] } })
+ await wrapper.setProps({ show: true })
+ await flushPromises()
+ return wrapper
+ }
+
+ it('shows the preview (path, masked entry, api-key note) before writing', async () => {
+ const wrapper = await open()
+
+ await wrapper.find('[data-test="connect-start-cursor"]').trigger('click')
+ await flushPromises()
+
+ // The write must NOT have happened yet — only the preview was fetched.
+ expect(api.getConnectPreview).toHaveBeenCalledWith('cursor')
+ expect(api.connectClient).not.toHaveBeenCalled()
+
+ const panel = wrapper.find('[data-test="connect-preview-cursor"]')
+ expect(panel.exists()).toBe(true)
+ expect(panel.text()).toContain('/Users/test/.cursor/mcp.json')
+ expect(panel.text()).toContain('Everything else in the file stays untouched')
+
+ const entry = wrapper.find('[data-test="connect-preview-entry-cursor"]')
+ expect(entry.exists()).toBe(true)
+ // Masked key visible, real key absent.
+ expect(entry.text()).toContain('••••')
+ expect(entry.text()).not.toContain('apikey=real')
+ expect(wrapper.find('[data-test="connect-preview-apikey-cursor"]').exists()).toBe(true)
+ })
+
+ it('confirm writes the file; cancel writes nothing', async () => {
+ const wrapper = await open()
+
+ // Cancel path first.
+ await wrapper.find('[data-test="connect-start-cursor"]').trigger('click')
+ await flushPromises()
+ await wrapper.find('[data-test="connect-preview-cancel-cursor"]').trigger('click')
+ await flushPromises()
+ expect(api.connectClient).not.toHaveBeenCalled()
+ expect(wrapper.find('[data-test="connect-preview-cursor"]').exists()).toBe(false)
+
+ // Confirm path writes.
+ await wrapper.find('[data-test="connect-start-cursor"]').trigger('click')
+ await flushPromises()
+ await wrapper.find('[data-test="connect-preview-confirm-cursor"]').trigger('click')
+ await flushPromises()
+ expect(api.connectClient).toHaveBeenCalledTimes(1)
+ // A create (entry_exists=false) confirms with force=false.
+ expect(api.connectClient).toHaveBeenCalledWith('cursor', 'mcpproxy', false)
+ })
+
+ it('an existing entry shows the overwrite warning and confirm implies force=true', async () => {
+ ;(api.getConnectPreview as any).mockResolvedValue(previewFor({ entry_exists: true }))
+ const wrapper = await open()
+
+ await wrapper.find('[data-test="connect-start-cursor"]').trigger('click')
+ await flushPromises()
+ expect(wrapper.find('[data-test="connect-preview-overwrite-cursor"]').exists()).toBe(true)
+
+ await wrapper.find('[data-test="connect-preview-confirm-cursor"]').trigger('click')
+ await flushPromises()
+ expect(api.connectClient).toHaveBeenCalledWith('cursor', 'mcpproxy', true)
+ })
+})
+
+describe('OnboardingWizard preview (Spec 078 US1)', () => {
+ let pinia: any
+
+ function onboardingState() {
+ return {
+ success: true,
+ data: {
+ has_connected_client: false,
+ has_configured_server: true,
+ connected_client_count: 0,
+ connected_client_ids: [],
+ configured_server_count: 1,
+ state: { engaged: false },
+ should_show_wizard: true,
+ first_mcp_client_ever: false,
+ mcp_clients_seen_ever: [],
+ incomplete_tab_count: 0,
+ },
+ }
+ }
+
+ beforeEach(() => {
+ pinia = createPinia()
+ setActivePinia(pinia)
+ vi.clearAllMocks()
+ ;(api.getActivities as any).mockResolvedValue({ success: true, data: { activities: [] } })
+ ;(api.getConfig as any).mockResolvedValue({ success: true, data: {} })
+ ;(api.getDockerStatus as any).mockResolvedValue({ success: true, data: { available: false } })
+ ;(api.getCanonicalConfigPaths as any).mockResolvedValue({ success: true, data: { paths: [] } })
+ ;(api.getOnboardingState as any).mockResolvedValue(onboardingState())
+ ;(api.markOnboardingState as any).mockResolvedValue(onboardingState())
+ ;(api.getConnectStatus as any).mockResolvedValue({ success: true, data: [cursorStatus()] })
+ ;(api.getConnectPreview as any).mockResolvedValue(previewFor())
+ ;(api.connectClient as any).mockResolvedValue(connectResult())
+ })
+
+ async function openClientsTab() {
+ const wrapper = mount(OnboardingWizard, { props: { show: false }, global: { plugins: [pinia] } })
+ await wrapper.setProps({ show: true })
+ await flushPromises()
+ await wrapper.find('[data-test="tab-clients"]').trigger('click')
+ await flushPromises()
+ return wrapper
+ }
+
+ it('previews before writing; cancel writes nothing, confirm proceeds', async () => {
+ const wrapper = await openClientsTab()
+
+ await wrapper.find('[data-test="connect-cursor"]').trigger('click')
+ await flushPromises()
+ expect(api.getConnectPreview).toHaveBeenCalledWith('cursor')
+ expect(api.connectClient).not.toHaveBeenCalled()
+
+ const panel = wrapper.find('[data-test="client-preview-cursor"]')
+ expect(panel.exists()).toBe(true)
+ expect(panel.text()).toContain('Everything else in the file stays untouched')
+ expect(wrapper.find('[data-test="client-preview-entry-cursor"]').text()).toContain('••••')
+
+ // Cancel writes nothing and dismisses the panel.
+ await wrapper.find('[data-test="client-preview-cancel-cursor"]').trigger('click')
+ await flushPromises()
+ expect(api.connectClient).not.toHaveBeenCalled()
+ expect(wrapper.find('[data-test="client-preview-cursor"]').exists()).toBe(false)
+
+ // Re-open and confirm.
+ await wrapper.find('[data-test="connect-cursor"]').trigger('click')
+ await flushPromises()
+ await wrapper.find('[data-test="client-preview-confirm-cursor"]').trigger('click')
+ await flushPromises()
+ expect(api.connectClient).toHaveBeenCalledWith('cursor', 'mcpproxy', false)
+ })
+
+ it('overwrite case confirms with force=true', async () => {
+ ;(api.getConnectPreview as any).mockResolvedValue(previewFor({ entry_exists: true }))
+ const wrapper = await openClientsTab()
+
+ await wrapper.find('[data-test="connect-cursor"]').trigger('click')
+ await flushPromises()
+ expect(wrapper.find('[data-test="client-preview-overwrite-cursor"]').exists()).toBe(true)
+
+ await wrapper.find('[data-test="client-preview-confirm-cursor"]').trigger('click')
+ await flushPromises()
+ expect(api.connectClient).toHaveBeenCalledWith('cursor', 'mcpproxy', true)
+ })
+})
diff --git a/frontend/tests/unit/onboarding-wizard-backup-path.spec.ts b/frontend/tests/unit/onboarding-wizard-backup-path.spec.ts
index 9ff8872a3..941dab300 100644
--- a/frontend/tests/unit/onboarding-wizard-backup-path.spec.ts
+++ b/frontend/tests/unit/onboarding-wizard-backup-path.spec.ts
@@ -18,10 +18,33 @@ vi.mock('@/services/api', () => ({
getConfig: vi.fn(),
getDockerStatus: vi.fn(),
getCanonicalConfigPaths: vi.fn(),
+ getConnectPreview: vi.fn(),
connectClient: vi.fn(),
},
}))
+// Spec 078 US1: a generic accessible-create preview. The wizard keys previews by
+// client id, not by the response's client field, so a single default serves all
+// rows; individual tests override entry_exists/access_state where relevant.
+function previewOk(overrides: Record = {}) {
+ return {
+ success: true,
+ data: {
+ client: 'cursor',
+ config_path: '/Users/test/.cursor/mcp.json',
+ format: 'json',
+ server_key: 'mcpServers',
+ server_name: 'mcpproxy',
+ entry: { type: 'sse', url: 'http://127.0.0.1:8080/mcp' },
+ entry_text: '{\n "mcpproxy": {\n "url": "http://127.0.0.1:8080/mcp",\n "type": "sse"\n }\n}',
+ entry_exists: false,
+ contains_api_key: false,
+ access_state: 'accessible',
+ ...overrides,
+ },
+ }
+}
+
function onboardingState(connectedIds: string[]) {
return {
success: true,
@@ -96,8 +119,19 @@ describe('OnboardingWizard backup path surfacing (Spec 078 US2)', () => {
;(api.getOnboardingState as any).mockResolvedValue(onboardingState([]))
;(api.markOnboardingState as any).mockResolvedValue(onboardingState([]))
;(api.getConnectStatus as any).mockResolvedValue({ success: true, data: [cursorClient()] })
+ // Spec 078 US1: Connect previews first; default to an accessible create.
+ ;(api.getConnectPreview as any).mockResolvedValue(previewOk())
})
+// Spec 078 US1: the wizard's Connect now opens a preview panel; the write only
+// happens on the panel's confirm button. Helper drives click → preview → confirm.
+async function connectViaPreview(wrapper: any, clientId: string) {
+ await wrapper.find(`[data-test="connect-${clientId}"]`).trigger('click')
+ await flushPromises()
+ await wrapper.find(`[data-test="client-preview-confirm-${clientId}"]`).trigger('click')
+ await flushPromises()
+}
+
it('shows the backup path in the client row after a successful connect', async () => {
;(api.connectClient as any).mockResolvedValue({
success: true,
@@ -119,8 +153,7 @@ describe('OnboardingWizard backup path surfacing (Spec 078 US2)', () => {
// No backup info before any connect happened in this session.
expect(wrapper.find('[data-test="client-backup-cursor"]').exists()).toBe(false)
- await wrapper.find('[data-test="connect-cursor"]').trigger('click')
- await flushPromises()
+ await connectViaPreview(wrapper, 'cursor')
const backup = wrapper.find('[data-test="client-backup-cursor"]')
expect(backup.exists()).toBe(true)
@@ -151,8 +184,7 @@ describe('OnboardingWizard backup path surfacing (Spec 078 US2)', () => {
const wrapper = await openClientsTab(pinia)
- await wrapper.find('[data-test="connect-cursor"]').trigger('click')
- await flushPromises()
+ await connectViaPreview(wrapper, 'cursor')
const backup = wrapper.find('[data-test="client-backup-cursor"]')
expect(backup.exists()).toBe(true)
@@ -193,8 +225,7 @@ describe('OnboardingWizard backup path surfacing (Spec 078 US2)', () => {
const connectBtn = wrapper.find('[data-test="connect-claude-desktop"]')
expect(connectBtn.exists()).toBe(true)
- await connectBtn.trigger('click')
- await flushPromises()
+ await connectViaPreview(wrapper, 'claude-desktop')
const backup = wrapper.find('[data-test="client-backup-claude-desktop"]')
expect(backup.exists()).toBe(true)
@@ -233,8 +264,7 @@ describe('OnboardingWizard backup path surfacing (Spec 078 US2)', () => {
})
const wrapper = await openClientsTab(pinia)
- await wrapper.find('[data-test="connect-cursor"]').trigger('click')
- await flushPromises()
+ await connectViaPreview(wrapper, 'cursor')
expect(wrapper.find('[data-test="client-backup-cursor"]').exists()).toBe(true)
// Close and reopen the wizard: a fresh session must start clean.
From 29f2c320c808cc40a4cc4f7387222471ad628fff Mon Sep 17 00:00:00 2001
From: Algis Dumbris
Date: Thu, 2 Jul 2026 17:47:23 +0300
Subject: [PATCH 3/7] fix(connect): stop leaking the API key into client
configs; auth-aware carrier
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Security fix folded into Spec 078. Connect (and the new preview) previously
appended ?apikey= to EVERY client entry via mcpURL(), but
/mcp does not require auth by default (config.RequireMCPAuth defaults false;
the admin context is granted unauthenticated). So connect leaked the
REST-admin API key in plaintext into other apps' config files where it
served no purpose.
New behavior, threaded from config.RequireMCPAuth into connect.Service the
same way listenAddr/apiKey are (server wiring + both CLI sites), via
WithRequireMCPAuth:
- require_mcp_auth=false (default): the entry carries NO credential — a clean
http://host:port/mcp URL, no query, no headers. Existing configs keep
working because /mcp accepts unauthenticated requests when protection is off.
- require_mcp_auth=true: the credential is written via the carrier each
client's real config schema supports (verified against vendor docs):
* X-API-Key header — claude-code, vscode, cursor, windsurf, gemini,
opencode (all support a "headers" object on their remote entry);
* mcp-remote --header "X-API-Key:" bridge arg — claude-desktop
(no space after the colon: Claude Desktop/Cursor don't escape spaces in
args and mangle the header — geelen/mcp-remote README; our keys are
space-free);
* ?apikey= query fallback — codex only, whose TOML HTTP transport takes
header values indirectly via env-var names (http_headers /
bearer_token_env_var) and cannot express a literal header value.
Server-side ExtractToken accepts X-API-Key, Authorization: Bearer, and
?apikey= (in that order), so header and query authenticate identically.
buildServerEntry now takes serverEntryParams{baseURL, credential}; mcpURL()
is replaced by the credential-free baseURL() anchor. Entry MATCHING
(findEntry*/findEquivalentJSONServerName/entryPointsToBridge) already keys on
baseURL with an optional ?query, so it recognizes BOTH legacy ?apikey=
entries and new clean entries — reconnect adopts/updates in place (no
duplicate) and disconnect still finds legacy entries; a force-reconnect over a
legacy entry upgrades it and drops the leaked key.
Preview reflects reality: contains_api_key is true only when require_mcp_auth
is on; the credential is masked in whichever carrier it uses (header value,
bridge arg, or query) and the real key never appears in the preview payload.
Tests: matrix over require_mcp_auth on/off × all 8 clients for both write and
preview (preview==write shape, auth-off writes contain NO apikey/headers at
all); per-client carrier assertions; legacy ?apikey= migration (recognized,
upgraded in place, no duplicate, disconnect still works). go test -race
./internal/connect/... ./internal/httpapi/... pass; server/config/management
pass; golangci-lint v2 clean.
Related #793
Co-Authored-By: Claude Fable 5
---
cmd/mcpproxy/connect_cmd.go | 4 +-
internal/connect/clients.go | 97 +++++++++-----
internal/connect/connect.go | 119 ++++++++++++-----
internal/connect/connect_test.go | 162 ++++++++++++++++-------
internal/connect/preview.go | 32 ++---
internal/connect/preview_test.go | 212 ++++++++++++++++++++++++++-----
internal/httpapi/connect_test.go | 6 +-
internal/server/server.go | 2 +-
8 files changed, 462 insertions(+), 172 deletions(-)
diff --git a/cmd/mcpproxy/connect_cmd.go b/cmd/mcpproxy/connect_cmd.go
index 444549c6b..4fd0b330c 100644
--- a/cmd/mcpproxy/connect_cmd.go
+++ b/cmd/mcpproxy/connect_cmd.go
@@ -76,7 +76,7 @@ func runConnect(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to load config: %w", err)
}
- svc := connect.NewService(cfg.Listen, cfg.APIKey)
+ svc := connect.NewService(cfg.Listen, cfg.APIKey).WithRequireMCPAuth(cfg.RequireMCPAuth)
format := clioutput.ResolveFormat(globalOutputFormat, globalJSONOutput)
formatter, err := clioutput.NewFormatter(format)
@@ -114,7 +114,7 @@ func runDisconnect(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to load config: %w", err)
}
- svc := connect.NewService(cfg.Listen, cfg.APIKey)
+ svc := connect.NewService(cfg.Listen, cfg.APIKey).WithRequireMCPAuth(cfg.RequireMCPAuth)
format := clioutput.ResolveFormat(globalOutputFormat, globalJSONOutput)
formatter, err := clioutput.NewFormatter(format)
diff --git a/internal/connect/clients.go b/internal/connect/clients.go
index 1df43ebb1..dfceaca56 100644
--- a/internal/connect/clients.go
+++ b/internal/connect/clients.go
@@ -180,58 +180,87 @@ func ConfigPath(clientID, homeDir string) string {
}
}
-// buildServerEntry returns the JSON-serializable map that should be inserted
-// into the client's config file for the given mcpproxy URL.
-func buildServerEntry(clientID, mcpURL string) map[string]interface{} {
+// buildServerEntry returns the JSON/TOML-serializable map inserted into the
+// client's config for the mcpproxy endpoint. When p.credential is set (only when
+// require_mcp_auth is on), it is written via the carrier each client actually
+// supports — an X-API-Key header where the config schema allows one, the
+// mcp-remote --header bridge arg for Claude Desktop, and the ?apikey= query only
+// for clients whose config cannot express a header (Codex). When p.credential is
+// empty (the default), a clean, keyless entry is written.
+//
+// Server-side ExtractToken accepts, in order, the X-API-Key header, an
+// Authorization: Bearer token, and the ?apikey= query, so the header carrier and
+// the query fallback authenticate identically.
+func buildServerEntry(clientID string, p serverEntryParams) map[string]interface{} {
switch clientID {
- case "claude-code":
- return map[string]interface{}{
+ case "claude-code", "vscode":
+ // Claude Code (~/.claude.json) and VS Code (mcp.json) "type":"http"
+ // entries support a "headers" object.
+ return withAPIKeyHeader(map[string]interface{}{
"type": "http",
- "url": mcpURL,
- }
+ "url": p.baseURL,
+ }, p.credential)
case "claude-desktop":
// Claude Desktop only speaks stdio, so bridge to mcpproxy's HTTP
- // endpoint with mcp-remote run via npx.
+ // endpoint with mcp-remote via npx. mcp-remote forwards the credential
+ // with --header. The value carries NO space after the colon: Claude
+ // Desktop / Cursor don't escape spaces in args and mangle the header
+ // (geelen/mcp-remote README) — mcpproxy's hex/token keys are space-free.
+ args := []string{"-y", "mcp-remote", p.baseURL}
+ if p.credential != "" {
+ args = append(args, "--header", "X-API-Key:"+p.credential)
+ }
return map[string]interface{}{
"command": "npx",
- "args": []string{"-y", "mcp-remote", mcpURL},
+ "args": args,
}
case "cursor":
- return map[string]interface{}{
- "url": mcpURL,
+ // Cursor mcp.json remote entries support a "headers" object.
+ return withAPIKeyHeader(map[string]interface{}{
+ "url": p.baseURL,
"type": "sse",
- }
+ }, p.credential)
case "windsurf":
- return map[string]interface{}{
- "serverUrl": mcpURL,
+ // Windsurf mcp_config.json serverUrl entries support a "headers" object.
+ return withAPIKeyHeader(map[string]interface{}{
+ "serverUrl": p.baseURL,
"type": "sse",
- }
- case "vscode":
- return map[string]interface{}{
- "type": "http",
- "url": mcpURL,
- }
- case "codex":
- // Codex speaks TOML; the entry is a single url key under
- // [mcp_servers.]. Constructed here (not inline in connectTOML) so
- // preview and write share the one source of truth (Spec 078 FR-002).
- return map[string]interface{}{
- "url": mcpURL,
- }
+ }, p.credential)
case "gemini":
- return map[string]interface{}{
- "httpUrl": mcpURL,
- }
+ // Gemini CLI settings.json httpUrl entries support a "headers" object.
+ return withAPIKeyHeader(map[string]interface{}{
+ "httpUrl": p.baseURL,
+ }, p.credential)
case "opencode":
- return map[string]interface{}{
+ // OpenCode opencode.json "type":"remote" entries support a "headers" object.
+ return withAPIKeyHeader(map[string]interface{}{
"type": "remote",
- "url": mcpURL,
+ "url": p.baseURL,
+ }, p.credential)
+ case "codex":
+ // Codex speaks TOML; the entry is a single url key under
+ // [mcp_servers.]. Its HTTP transport only accepts header VALUES
+ // indirectly via env-var names (http_headers / bearer_token_env_var), so
+ // a literal X-API-Key header can't be written — fall back to the ?apikey=
+ // query, which mcpproxy's /mcp accepts. Constructed here (not inline in
+ // connectTOML) so preview and write share one source of truth (FR-002).
+ return map[string]interface{}{
+ "url": credentialQuery(p.baseURL, p.credential),
}
default:
- // Fallback: generic HTTP entry
+ // Fallback: generic HTTP entry with the query carrier (no known schema).
return map[string]interface{}{
"type": "http",
- "url": mcpURL,
+ "url": credentialQuery(p.baseURL, p.credential),
}
}
}
+
+// withAPIKeyHeader adds an X-API-Key header object to a JSON client entry when a
+// credential is present, and returns the entry unchanged otherwise.
+func withAPIKeyHeader(entry map[string]interface{}, credential string) map[string]interface{} {
+ if credential != "" {
+ entry["headers"] = map[string]interface{}{"X-API-Key": credential}
+ }
+ return entry
+}
diff --git a/internal/connect/connect.go b/internal/connect/connect.go
index 3d2d9e0d1..fb6c9d582 100644
--- a/internal/connect/connect.go
+++ b/internal/connect/connect.go
@@ -62,11 +62,27 @@ type Service struct {
listenAddr string // e.g. "127.0.0.1:8080"
apiKey string // optional API key
homeDir string // override for testing; empty means use os.UserHomeDir
+ // requireMCPAuth mirrors config.RequireMCPAuth. When false (the default),
+ // the /mcp endpoint accepts unauthenticated requests, so connect writes NO
+ // credential into client configs — embedding the REST-admin API key there
+ // would leak it for no benefit (Spec 078 security fix). When true, the
+ // credential is written via an HTTP header where the client config supports
+ // one, falling back to the ?apikey= query only where it cannot.
+ requireMCPAuth bool
// readFile is the content-read seam (Spec 075 T003). Defaults to os.ReadFile;
// tests inject a permission-denied error or a call counter through it.
readFile func(string) ([]byte, error)
}
+// WithRequireMCPAuth sets whether the /mcp endpoint requires authentication,
+// which decides whether connect embeds a credential in client configs at all.
+// Threaded from config.RequireMCPAuth at the wiring sites, alongside listenAddr
+// and apiKey. Returns the receiver for chaining.
+func (s *Service) WithRequireMCPAuth(v bool) *Service {
+ s.requireMCPAuth = v
+ return s
+}
+
// NewService creates a Service that will inject the given listen address
// and optional API key into client configurations.
func NewService(listenAddr, apiKey string) *Service {
@@ -110,18 +126,62 @@ func (s *Service) read(path string) ([]byte, error) {
return os.ReadFile(path)
}
-// mcpURL builds the MCPProxy MCP endpoint URL.
-func (s *Service) mcpURL() string {
+// baseURL builds the credential-free MCPProxy MCP endpoint URL. This is the
+// anchor used both to construct client entries and to recognize existing ones
+// (with or without a trailing ?apikey= query), so matching works across the
+// pre- and post-Spec-078 entry shapes.
+func (s *Service) baseURL() string {
addr := s.listenAddr
// If listen address starts with ":" (no host), default to localhost
if strings.HasPrefix(addr, ":") {
addr = "127.0.0.1" + addr
}
- base := fmt.Sprintf("http://%s/mcp", addr)
- if s.apiKey != "" {
- base += "?apikey=" + url.QueryEscape(s.apiKey)
+ return fmt.Sprintf("http://%s/mcp", addr)
+}
+
+// serverEntryParams carries everything buildServerEntry needs. credential is the
+// value to embed in the entry (as an X-API-Key header, a --header bridge arg, or
+// an ?apikey= query, per client). It is empty when no credential should be
+// written (require_mcp_auth off, or no key) and may hold the mask token in a
+// preview.
+type serverEntryParams struct {
+ baseURL string
+ credential string
+}
+
+// entryParams resolves the credential to embed. When require_mcp_auth is off, or
+// no API key is set, credential stays empty so connect writes a clean, keyless
+// entry. When masked is true the real key is replaced with the display mask for
+// previews (the real key never leaves the core in a preview payload).
+func (s *Service) entryParams(masked bool) serverEntryParams {
+ cred := ""
+ if s.requireMCPAuth && s.apiKey != "" {
+ cred = s.apiKey
+ if masked {
+ cred = apiKeyMask
+ }
+ }
+ return serverEntryParams{baseURL: s.baseURL(), credential: cred}
+}
+
+// containsCredential reports whether connect will write a credential into the
+// client config for the current configuration.
+func (s *Service) containsCredential() bool {
+ return s.requireMCPAuth && s.apiKey != ""
+}
+
+// credentialQuery appends the credential as an ?apikey= query to base, for the
+// clients whose config cannot express an HTTP header. The real key is
+// URL-escaped; the display mask is left literal so the preview renders cleanly.
+func credentialQuery(base, credential string) string {
+ if credential == "" {
+ return base
+ }
+ v := credential
+ if credential != apiKeyMask {
+ v = url.QueryEscape(credential)
}
- return base
+ return base + "?apikey=" + v
}
// defaultServerName is the key used in client config files.
@@ -290,14 +350,12 @@ func (s *Service) Connect(clientID, serverName string, force bool) (*ConnectResu
}
}
- mcpURL := s.mcpURL()
-
var res *ConnectResult
var err error
if client.Format == "toml" {
- res, err = s.connectTOML(client, cfgPath, serverName, mcpURL, force)
+ res, err = s.connectTOML(client, cfgPath, serverName, force)
} else {
- res, err = s.connectJSON(client, cfgPath, serverName, mcpURL, force)
+ res, err = s.connectJSON(client, cfgPath, serverName, force)
}
// A permission denial anywhere in the read/backup/write chain (the errors
// preserve their OS cause via %w) surfaces as a typed *AccessError with
@@ -337,7 +395,7 @@ func (s *Service) Disconnect(clientID, serverName string) (*ConnectResult, error
// ---------- JSON helpers ----------
// connectJSON adds or updates the mcpproxy entry in a JSON config file.
-func (s *Service) connectJSON(client *ClientDef, cfgPath, serverName, mcpURL string, force bool) (*ConnectResult, error) {
+func (s *Service) connectJSON(client *ClientDef, cfgPath, serverName string, force bool) (*ConnectResult, error) {
// Read existing config or start fresh
data, perm, err := s.readOrCreateJSON(cfgPath)
if err != nil {
@@ -367,7 +425,7 @@ func (s *Service) connectJSON(client *ClientDef, cfgPath, serverName, mcpURL str
}
if client.ID == "opencode" {
- if adoptedName, found := findEquivalentJSONServerName(serversMap, mcpURL, serverName); found && adoptedName != serverName {
+ if adoptedName, found := findEquivalentJSONServerName(serversMap, s.baseURL(), serverName); found && adoptedName != serverName {
if !force {
return &ConnectResult{
Success: true,
@@ -389,8 +447,9 @@ func (s *Service) connectJSON(client *ClientDef, cfgPath, serverName, mcpURL str
return nil, fmt.Errorf("backup failed: %w", err)
}
- // Build the entry
- entry := buildServerEntry(client.ID, mcpURL)
+ // Build the entry from the credential-aware params (no credential unless
+ // require_mcp_auth is on).
+ entry := buildServerEntry(client.ID, s.entryParams(false))
serversMap[serverName] = entry
data[serversKey] = serversMap
@@ -504,7 +563,7 @@ func (s *Service) disconnectJSON(client *ClientDef, cfgPath, serverName string)
// ---------- TOML helpers (Codex) ----------
// connectTOML adds or updates the mcpproxy entry in a TOML config file (Codex).
-func (s *Service) connectTOML(client *ClientDef, cfgPath, serverName, mcpURL string, force bool) (*ConnectResult, error) {
+func (s *Service) connectTOML(client *ClientDef, cfgPath, serverName string, force bool) (*ConnectResult, error) {
data, perm, err := s.readOrCreateTOML(cfgPath)
if err != nil {
return nil, err
@@ -543,7 +602,7 @@ func (s *Service) connectTOML(client *ClientDef, cfgPath, serverName, mcpURL str
// Build Codex entry via the shared constructor so what connect writes is
// exactly what preview renders (Spec 078 FR-002).
- entry := buildServerEntry(client.ID, mcpURL)
+ entry := buildServerEntry(client.ID, s.entryParams(false))
serversMap[serverName] = entry
data["mcp_servers"] = serversMap
@@ -737,8 +796,7 @@ func (s *Service) verifyJSONEntry(path, serversKey, serverName string) error {
return nil
}
-func findEquivalentJSONServerName(serversMap map[string]interface{}, mcpURL, requestedServerName string) (string, bool) {
- baseURL := strings.SplitN(mcpURL, "?", 2)[0]
+func findEquivalentJSONServerName(serversMap map[string]interface{}, baseURL, requestedServerName string) (string, bool) {
for name, rawEntry := range serversMap {
entry, ok := rawEntry.(map[string]interface{})
if !ok {
@@ -749,7 +807,9 @@ func findEquivalentJSONServerName(serversMap map[string]interface{}, mcpURL, req
if !ok {
continue
}
- if entryURL == mcpURL || entryURL == baseURL || strings.HasPrefix(entryURL, baseURL+"?") {
+ // Match both a clean base URL and a legacy ?apikey= variant so an
+ // upgrade adopts/updates the existing entry rather than duplicating.
+ if entryURL == baseURL || strings.HasPrefix(entryURL, baseURL+"?") {
return name, true
}
}
@@ -785,8 +845,9 @@ func (s *Service) findEntryJSONBytes(client ClientDef, raw []byte) (name string,
return "", false, true
}
- mcpURL := s.mcpURL()
- baseURL := fmt.Sprintf("http://%s/mcp", s.listenAddr)
+ // Anchor on the credential-free base URL so both new clean entries and
+ // legacy entries carrying a ?apikey= query are recognized (Spec 078).
+ baseURL := s.baseURL()
for name, v := range serversMap {
entry, ok := v.(map[string]interface{})
@@ -797,7 +858,7 @@ func (s *Service) findEntryJSONBytes(client ClientDef, raw []byte) (name string,
// Check various URL fields used by different clients
for _, field := range []string{"url", "serverUrl", "httpUrl"} {
if u, ok := entry[field].(string); ok {
- if u == mcpURL || u == baseURL || strings.HasPrefix(u, baseURL+"?") {
+ if u == baseURL || strings.HasPrefix(u, baseURL+"?") {
return name, true, true
}
}
@@ -806,7 +867,7 @@ func (s *Service) findEntryJSONBytes(client ClientDef, raw []byte) (name string,
// Stdio-bridge clients (e.g. Claude Desktop) have no URL field; the
// mcpproxy endpoint lives in the command args. Detect by inspecting
// args so a bridge written under a custom server name is still found.
- if entryPointsToBridge(entry, mcpURL, baseURL) {
+ if entryPointsToBridge(entry, baseURL) {
return name, true, true
}
@@ -820,8 +881,9 @@ func (s *Service) findEntryJSONBytes(client ClientDef, raw []byte) (name string,
}
// entryPointsToBridge reports whether a JSON config entry is an mcp-remote
-// stdio bridge targeting our MCP endpoint, regardless of the entry key.
-func entryPointsToBridge(entry map[string]interface{}, mcpURL, baseURL string) bool {
+// stdio bridge targeting our MCP endpoint, regardless of the entry key. Matches
+// both the clean base URL (new entries) and a legacy ?apikey= variant.
+func entryPointsToBridge(entry map[string]interface{}, baseURL string) bool {
rawArgs, ok := entry["args"].([]interface{})
if !ok {
return false
@@ -836,7 +898,7 @@ func entryPointsToBridge(entry map[string]interface{}, mcpURL, baseURL string) b
if s == "mcp-remote" {
hasBridgePkg = true
}
- if s == mcpURL || s == baseURL || strings.HasPrefix(s, baseURL+"?") {
+ if s == baseURL || strings.HasPrefix(s, baseURL+"?") {
pointsToUs = true
}
}
@@ -871,8 +933,7 @@ func (s *Service) findEntryTOMLBytes(raw []byte) (name string, found, parsedOK b
return "", false, true
}
- mcpURL := s.mcpURL()
- baseURL := fmt.Sprintf("http://%s/mcp", s.listenAddr)
+ baseURL := s.baseURL()
for name, v := range serversMap {
entry, ok := v.(map[string]interface{})
@@ -880,7 +941,7 @@ func (s *Service) findEntryTOMLBytes(raw []byte) (name string, found, parsedOK b
continue
}
if u, ok := entry["url"].(string); ok {
- if u == mcpURL || u == baseURL || strings.HasPrefix(u, baseURL+"?") {
+ if u == baseURL || strings.HasPrefix(u, baseURL+"?") {
return name, true, true
}
}
diff --git a/internal/connect/connect_test.go b/internal/connect/connect_test.go
index 971cd9cab..e3698bdfe 100644
--- a/internal/connect/connect_test.go
+++ b/internal/connect/connect_test.go
@@ -283,8 +283,10 @@ func TestConnect_ClaudeCode_NewFile(t *testing.T) {
}
}
-func TestConnect_ClaudeCode_WithAPIKey(t *testing.T) {
- svc, _ := testServiceWithKey(t)
+// Spec 078 security fix: with require_mcp_auth off (default), claude-code gets a
+// clean, keyless entry — the REST-admin key is NOT leaked into the config.
+func TestConnect_ClaudeCode_AuthOff_NoKeyWritten(t *testing.T) {
+ svc, _ := testServiceWithKey(t) // key set but require_mcp_auth off
result, err := svc.Connect("claude-code", "", false)
if err != nil {
@@ -298,18 +300,49 @@ func TestConnect_ClaudeCode_WithAPIKey(t *testing.T) {
if err != nil {
t.Fatalf("Read config failed: %v", err)
}
+ if strings.Contains(string(raw), "apikey") || strings.Contains(string(raw), "test-key-123") || strings.Contains(string(raw), "headers") {
+ t.Fatalf("auth-off connect must not embed a credential, got: %s", raw)
+ }
var data map[string]interface{}
if err := json.Unmarshal(raw, &data); err != nil {
t.Fatalf("Parse config failed: %v", err)
}
+ entry := data["mcpServers"].(map[string]interface{})["mcpproxy"].(map[string]interface{})
+ if entry["url"] != "http://127.0.0.1:8080/mcp" {
+ t.Errorf("Expected clean url, got %v", entry["url"])
+ }
+}
- servers := data["mcpServers"].(map[string]interface{})
- entry := servers["mcpproxy"].(map[string]interface{})
+// With require_mcp_auth on, claude-code carries the credential in an X-API-Key
+// header (its config schema supports one) and the URL stays clean.
+func TestConnect_ClaudeCode_AuthOn_UsesHeader(t *testing.T) {
+ svc, _ := testServiceWithKey(t)
+ svc.WithRequireMCPAuth(true)
+
+ result, err := svc.Connect("claude-code", "", false)
+ if err != nil {
+ t.Fatalf("Connect failed: %v", err)
+ }
- expectedURL := "http://127.0.0.1:8080/mcp?apikey=test-key-123"
- if entry["url"] != expectedURL {
- t.Errorf("Expected url=%s, got %v", expectedURL, entry["url"])
+ raw, err := os.ReadFile(result.ConfigPath)
+ if err != nil {
+ t.Fatalf("Read config failed: %v", err)
+ }
+ var data map[string]interface{}
+ if err := json.Unmarshal(raw, &data); err != nil {
+ t.Fatalf("Parse config failed: %v", err)
+ }
+ entry := data["mcpServers"].(map[string]interface{})["mcpproxy"].(map[string]interface{})
+ if entry["url"] != "http://127.0.0.1:8080/mcp" {
+ t.Errorf("Expected clean url with header carrier, got %v", entry["url"])
+ }
+ headers, ok := entry["headers"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("Expected headers object, got %T", entry["headers"])
+ }
+ if headers["X-API-Key"] != "test-key-123" {
+ t.Errorf("Expected X-API-Key header, got %v", headers["X-API-Key"])
}
}
@@ -766,7 +799,7 @@ func TestClaudeDesktop_SupportedWithBridgeNote(t *testing.T) {
}
func TestBuildServerEntry_ClaudeDesktop_StdioBridge(t *testing.T) {
- entry := buildServerEntry("claude-desktop", "http://127.0.0.1:8080/mcp")
+ entry := buildServerEntry("claude-desktop", serverEntryParams{baseURL: "http://127.0.0.1:8080/mcp"})
if entry["command"] != "npx" {
t.Errorf("expected command=npx, got %v", entry["command"])
@@ -838,29 +871,49 @@ func TestConnect_ClaudeDesktop_WritesStdioBridge(t *testing.T) {
}
}
-func TestConnect_ClaudeDesktop_BridgeIncludesAPIKey(t *testing.T) {
- svc, homeDir := testServiceWithKey(t)
-
- cfgPath := ConfigPath("claude-desktop", homeDir)
- if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
- t.Fatal(err)
- }
-
- result, err := svc.Connect("claude-desktop", "", false)
- if err != nil {
- t.Fatalf("Connect failed: %v", err)
- }
+// With require_mcp_auth on, the Claude Desktop bridge forwards the credential
+// via an mcp-remote --header arg (no space after the colon), and the URL arg
+// stays clean. With auth off it embeds no credential at all.
+func TestConnect_ClaudeDesktop_BridgeCredentialCarrier(t *testing.T) {
+ for _, authOn := range []bool{false, true} {
+ authOn := authOn
+ t.Run(map[bool]string{true: "auth-on", false: "auth-off"}[authOn], func(t *testing.T) {
+ svc, homeDir := testServiceWithKey(t)
+ svc.WithRequireMCPAuth(authOn)
+
+ cfgPath := ConfigPath("claude-desktop", homeDir)
+ if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
- raw, _ := os.ReadFile(result.ConfigPath)
- var data map[string]interface{}
- json.Unmarshal(raw, &data)
+ result, err := svc.Connect("claude-desktop", "", false)
+ if err != nil {
+ t.Fatalf("Connect failed: %v", err)
+ }
- servers := data["mcpServers"].(map[string]interface{})
- entry := servers["mcpproxy"].(map[string]interface{})
- args := entry["args"].([]interface{})
- lastArg, _ := args[len(args)-1].(string)
- if lastArg != "http://127.0.0.1:8080/mcp?apikey=test-key-123" {
- t.Errorf("Expected bridge URL with apikey, got %v", lastArg)
+ raw, _ := os.ReadFile(result.ConfigPath)
+ var data map[string]interface{}
+ if err := json.Unmarshal(raw, &data); err != nil {
+ t.Fatal(err)
+ }
+ entry := data["mcpServers"].(map[string]interface{})["mcpproxy"].(map[string]interface{})
+ args := entry["args"].([]interface{})
+ // The mcpproxy URL arg (index 2) is always the clean base URL.
+ if args[2] != "http://127.0.0.1:8080/mcp" {
+ t.Errorf("Expected clean bridge URL arg, got %v", args[2])
+ }
+ if authOn {
+ lastArg, _ := args[len(args)-1].(string)
+ if lastArg != "X-API-Key:test-key-123" {
+ t.Errorf("Expected --header X-API-Key:test-key-123, got %v", lastArg)
+ }
+ if args[len(args)-2] != "--header" {
+ t.Errorf("Expected --header flag before value, got %v", args[len(args)-2])
+ }
+ } else if strings.Contains(string(raw), "test-key-123") || strings.Contains(string(raw), "--header") {
+ t.Errorf("auth-off bridge must embed no credential, got: %s", raw)
+ }
+ })
}
}
@@ -1097,33 +1150,46 @@ func TestConfigPath_UnknownClient(t *testing.T) {
}
}
-// ---------- mcpURL tests ----------
+// ---------- baseURL / credential-carrier tests ----------
-func TestMcpURL_NoAPIKey(t *testing.T) {
- svc := NewService("127.0.0.1:8080", "")
- url := svc.mcpURL()
- if url != "http://127.0.0.1:8080/mcp" {
- t.Errorf("Expected http://127.0.0.1:8080/mcp, got %s", url)
+func TestBaseURL_NoQuery(t *testing.T) {
+ // The endpoint anchor is always credential-free, regardless of api key.
+ svc := NewService("127.0.0.1:8080", "my-secret")
+ if got := svc.baseURL(); got != "http://127.0.0.1:8080/mcp" {
+ t.Errorf("Expected clean base URL, got %s", got)
}
}
-func TestMcpURL_WithAPIKey(t *testing.T) {
- svc := NewService("127.0.0.1:8080", "my-secret")
- url := svc.mcpURL()
- if url != "http://127.0.0.1:8080/mcp?apikey=my-secret" {
- t.Errorf("Expected url with apikey, got %s", url)
+// Spec 078 security fix: with require_mcp_auth off (the default), no credential
+// is written into a client config even when an API key is configured.
+func TestEntryParams_AuthOff_NoCredential(t *testing.T) {
+ svc := NewService("127.0.0.1:8080", "my-secret") // require_mcp_auth defaults false
+ p := svc.entryParams(false)
+ if p.credential != "" {
+ t.Errorf("expected no credential when auth is off, got %q", p.credential)
+ }
+ if svc.containsCredential() {
+ t.Error("containsCredential must be false when auth is off")
}
}
-func TestMcpURL_APIKeyWithSpecialChars(t *testing.T) {
- svc := NewService("127.0.0.1:8080", "key with spaces&special=chars")
- url := svc.mcpURL()
- if !strings.Contains(url, "apikey=") {
- t.Errorf("Expected apikey param in URL, got %s", url)
+// With require_mcp_auth on, the credential is present and the query carrier
+// URL-escapes special characters.
+func TestEntryParams_AuthOn_CredentialPresent(t *testing.T) {
+ svc := NewService("127.0.0.1:8080", "key with spaces&x=1").WithRequireMCPAuth(true)
+ p := svc.entryParams(false)
+ if p.credential != "key with spaces&x=1" {
+ t.Errorf("expected raw credential, got %q", p.credential)
+ }
+ if !svc.containsCredential() {
+ t.Error("containsCredential must be true when auth is on with a key")
+ }
+ q := credentialQuery(p.baseURL, p.credential)
+ if strings.Contains(q, " ") {
+ t.Errorf("query carrier must URL-escape spaces, got %s", q)
}
- // Should be URL-encoded
- if strings.Contains(url, " ") {
- t.Error("URL should not contain raw spaces")
+ if !strings.Contains(q, "apikey=") {
+ t.Errorf("expected apikey query, got %s", q)
}
}
diff --git a/internal/connect/preview.go b/internal/connect/preview.go
index 39f7088a0..884e0f593 100644
--- a/internal/connect/preview.go
+++ b/internal/connect/preview.go
@@ -4,21 +4,17 @@ import (
"bytes"
"fmt"
"os"
- "regexp"
"github.com/BurntSushi/toml"
)
-// apiKeyMask is the placeholder substituted for the real apikey query value in a
-// preview. It is deliberately human-readable (not percent-encoded) so the user
-// plainly sees a credential is written without the secret ever leaving the core
-// in a preview payload, log, or telemetry event (Spec 078 FR-004).
+// apiKeyMask is the placeholder substituted for the real credential in a
+// preview, whether it is carried in a header value, a bridge --header arg, or an
+// ?apikey= query. It is deliberately human-readable (not percent-encoded) so the
+// user plainly sees a credential is written without the secret ever leaving the
+// core in a preview payload, log, or telemetry event (Spec 078 FR-004).
const apiKeyMask = "••••" // ••••
-// apiKeyQueryPattern matches an apikey query parameter value in the MCP URL,
-// capturing the `?apikey=` / `&apikey=` prefix so only the value is replaced.
-var apiKeyQueryPattern = regexp.MustCompile(`([?&]apikey=)[^&]*`)
-
// ConnectPreview describes the exact change a subsequent Connect would make to a
// client config, WITHOUT modifying the file or creating a backup (Spec 078 US1).
// The entry is derived from the same buildServerEntry used by the real write, so
@@ -41,13 +37,6 @@ type ConnectPreview struct {
AccessState string `json:"access_state"`
}
-// maskURLAPIKey replaces the apikey query value with a fixed mask, leaving the
-// rest of the URL (scheme, host, path, other params) intact. When no apikey is
-// present it returns the URL unchanged.
-func maskURLAPIKey(raw string) string {
- return apiKeyQueryPattern.ReplaceAllString(raw, `${1}`+apiKeyMask)
-}
-
// Preview computes the exact entry a Connect would write for the given client,
// without modifying the config or creating a backup (Spec 078 FR-001). It reads
// the config on demand only to classify create-vs-overwrite (FR-003) and to
@@ -97,12 +86,11 @@ func (s *Service) Preview(clientID, serverName string) (*ConnectPreview, error)
}
}
- // Build the entry from the SAME constructor the write uses, then mask the
- // embedded credential for display. Because the real write also calls
+ // Build the entry from the SAME constructor the write uses, with the
+ // credential masked for display. Because the real write also calls
// buildServerEntry, the masked entry differs from the written entry only in
- // the apikey value — the shape and every other field are identical.
- maskedURL := maskURLAPIKey(s.mcpURL())
- maskedEntry := buildServerEntry(clientID, maskedURL)
+ // the credential value — the carrier, shape, and every other field match.
+ maskedEntry := buildServerEntry(clientID, s.entryParams(true))
entryText, err := renderEntrySnippet(client, serverName, maskedEntry)
if err != nil {
@@ -118,7 +106,7 @@ func (s *Service) Preview(clientID, serverName string) (*ConnectPreview, error)
Entry: maskedEntry,
EntryText: entryText,
EntryExists: entryExists,
- ContainsAPIKey: s.apiKey != "",
+ ContainsAPIKey: s.containsCredential(),
Bridge: client.Bridge,
AccessState: accessState,
}, nil
diff --git a/internal/connect/preview_test.go b/internal/connect/preview_test.go
index 2c2719698..01b55fbef 100644
--- a/internal/connect/preview_test.go
+++ b/internal/connect/preview_test.go
@@ -95,50 +95,75 @@ func readWrittenEntry(t *testing.T, svc *Service, clientID, serverName string) m
// It compares against the SHARED constructor with the real (unmasked) URL, then
// separately confirms the preview payload masks that URL.
func TestPreview_EqualsWrite(t *testing.T) {
- for _, clientID := range supportedPreviewClients {
- clientID := clientID
- t.Run(clientID, func(t *testing.T) {
- svc, home := testServiceWithKey(t)
- seedClientConfig(t, home, clientID)
-
- preview, err := svc.Preview(clientID, "mcpproxy")
- if err != nil {
- t.Fatalf("Preview: %v", err)
+ // Matrix: every supported client × require_mcp_auth on/off. The previewed
+ // entry must equal the written entry's shape for the same configuration
+ // (Spec 078 SC-004), with the credential masked in the preview only.
+ for _, authOn := range []bool{false, true} {
+ authOn := authOn
+ for _, clientID := range supportedPreviewClients {
+ clientID := clientID
+ name := clientID
+ if authOn {
+ name += "/auth-on"
+ } else {
+ name += "/auth-off"
}
+ t.Run(name, func(t *testing.T) {
+ svc, home := testServiceWithKey(t)
+ svc.WithRequireMCPAuth(authOn)
+ seedClientConfig(t, home, clientID)
- res, err := svc.Connect(clientID, "mcpproxy", true)
- if err != nil {
- t.Fatalf("Connect: %v", err)
- }
- if !res.Success {
- t.Fatalf("Connect not successful: %+v", res)
- }
+ preview, err := svc.Preview(clientID, "mcpproxy")
+ if err != nil {
+ t.Fatalf("Preview: %v", err)
+ }
- written := readWrittenEntry(t, svc, clientID, "mcpproxy")
+ res, err := svc.Connect(clientID, "mcpproxy", true)
+ if err != nil {
+ t.Fatalf("Connect: %v", err)
+ }
+ if !res.Success {
+ t.Fatalf("Connect not successful: %+v", res)
+ }
- // The write uses buildServerEntry(clientID, realURL); the shared
- // constructor is the single source of truth for preview and write.
- wantUnmasked := buildServerEntry(clientID, svc.mcpURL())
- if got, want := marshalEntry(t, written), marshalEntry(t, wantUnmasked); got != want {
- t.Fatalf("written entry != shared constructor output\n got: %s\nwant: %s", got, want)
- }
+ written := readWrittenEntry(t, svc, clientID, "mcpproxy")
- // The preview payload masks the credential: its entry equals the
- // shared constructor fed the masked URL, and never the real key.
- wantMasked := buildServerEntry(clientID, maskURLAPIKey(svc.mcpURL()))
- if got, want := marshalEntry(t, preview.Entry), marshalEntry(t, wantMasked); got != want {
- t.Fatalf("preview entry != masked constructor output\n got: %s\nwant: %s", got, want)
- }
- })
+ // The write and preview both call buildServerEntry — the single
+ // source of truth. Unmasked params == written; masked == preview.
+ wantUnmasked := buildServerEntry(clientID, svc.entryParams(false))
+ if got, want := marshalEntry(t, written), marshalEntry(t, wantUnmasked); got != want {
+ t.Fatalf("written entry != shared constructor output\n got: %s\nwant: %s", got, want)
+ }
+ wantMasked := buildServerEntry(clientID, svc.entryParams(true))
+ if got, want := marshalEntry(t, preview.Entry), marshalEntry(t, wantMasked); got != want {
+ t.Fatalf("preview entry != masked constructor output\n got: %s\nwant: %s", got, want)
+ }
+
+ // Spec 078 security fix: with auth off, the written config must
+ // contain no credential at all.
+ if !authOn {
+ if strings.Contains(marshalEntry(t, written), "apikey") ||
+ strings.Contains(marshalEntry(t, written), "test-key-123") ||
+ strings.Contains(marshalEntry(t, written), "headers") {
+ t.Fatalf("auth-off write must embed no credential, got: %s", marshalEntry(t, written))
+ }
+ if preview.ContainsAPIKey {
+ t.Fatal("auth-off preview must report contains_api_key=false")
+ }
+ }
+ })
+ }
}
}
-// TestPreview_MasksAPIKey verifies the real API key never appears anywhere in
-// the preview payload while ContainsAPIKey honestly flags that a credential is
-// written, and the base URL stays visible (Spec 078 FR-004).
-func TestPreview_MasksAPIKey(t *testing.T) {
+// TestPreview_MasksCredential verifies the real API key never appears anywhere
+// in the preview payload while ContainsAPIKey honestly flags that a credential
+// is written, and the base URL stays visible (Spec 078 FR-004). Runs with
+// require_mcp_auth on (the only mode that writes a credential).
+func TestPreview_MasksCredential(t *testing.T) {
const secret = "super-secret-key-1234"
svc, home := serviceWithKey(t, secret)
+ svc.WithRequireMCPAuth(true)
seedClientConfig(t, home, "claude-code")
preview, err := svc.Preview("claude-code", "mcpproxy")
@@ -154,7 +179,7 @@ func TestPreview_MasksAPIKey(t *testing.T) {
t.Fatalf("real API key leaked into preview payload: %s", payload)
}
if !preview.ContainsAPIKey {
- t.Fatal("ContainsAPIKey must be true when the URL embeds a credential")
+ t.Fatal("ContainsAPIKey must be true when auth is on with a key")
}
if !strings.Contains(preview.EntryText, apiKeyMask) {
t.Fatalf("EntryText should show the mask, got: %s", preview.EntryText)
@@ -162,6 +187,10 @@ func TestPreview_MasksAPIKey(t *testing.T) {
if !strings.Contains(preview.EntryText, "http://127.0.0.1:8080/mcp") {
t.Fatalf("EntryText should keep the base URL visible, got: %s", preview.EntryText)
}
+ // claude-code carries the credential in a header, so the URL stays clean.
+ if strings.Contains(preview.EntryText, "apikey=") {
+ t.Fatalf("claude-code should use a header, not an apikey query: %s", preview.EntryText)
+ }
}
// TestPreview_NoAPIKey: with no key configured, ContainsAPIKey is false and no
@@ -337,3 +366,116 @@ func TestPreview_UnknownClient(t *testing.T) {
t.Fatal("expected error for unknown client")
}
}
+
+// TestConnect_CredentialCarrierMatrix pins the per-client credential carrier
+// when require_mcp_auth is on (Spec 078): a header where the client config
+// supports one, the mcp-remote --header bridge arg for Claude Desktop, and the
+// ?apikey= query only for Codex (whose TOML headers are env-var indirected).
+func TestConnect_CredentialCarrierMatrix(t *testing.T) {
+ type carrier int
+ const (
+ header carrier = iota
+ bridgeHeaderArg
+ query
+ )
+ want := map[string]carrier{
+ "claude-code": header,
+ "vscode": header,
+ "cursor": header,
+ "windsurf": header,
+ "gemini": header,
+ "opencode": header,
+ "claude-desktop": bridgeHeaderArg,
+ "codex": query,
+ }
+
+ for clientID, c := range want {
+ clientID, c := clientID, c
+ t.Run(clientID, func(t *testing.T) {
+ svc, home := testServiceWithKey(t)
+ svc.WithRequireMCPAuth(true)
+ seedClientConfig(t, home, clientID)
+
+ if _, err := svc.Connect(clientID, "mcpproxy", true); err != nil {
+ t.Fatalf("Connect: %v", err)
+ }
+ entry := readWrittenEntry(t, svc, clientID, "mcpproxy")
+
+ switch c {
+ case header:
+ h, ok := entry["headers"].(map[string]interface{})
+ if !ok || h["X-API-Key"] != "test-key-123" {
+ t.Fatalf("%s: expected X-API-Key header, got %v", clientID, entry)
+ }
+ // URL carrier must NOT also carry the key.
+ for _, f := range []string{"url", "serverUrl", "httpUrl"} {
+ if u, ok := entry[f].(string); ok && strings.Contains(u, "apikey") {
+ t.Fatalf("%s: header client must keep URL clean, got %v", clientID, u)
+ }
+ }
+ case bridgeHeaderArg:
+ args := entry["args"].([]interface{})
+ last, _ := args[len(args)-1].(string)
+ if last != "X-API-Key:test-key-123" {
+ t.Fatalf("%s: expected --header arg, got %v", clientID, args)
+ }
+ case query:
+ u, _ := entry["url"].(string)
+ if !strings.Contains(u, "apikey=test-key-123") {
+ t.Fatalf("%s: expected apikey query, got %v", clientID, u)
+ }
+ }
+ })
+ }
+}
+
+// TestConnect_LegacyEntryMigration proves an upgrade over a legacy entry that
+// carried ?apikey= is recognized and updated in place (not duplicated), and the
+// upgraded entry drops the leaked key when require_mcp_auth is off (Spec 078).
+func TestConnect_LegacyEntryMigration(t *testing.T) {
+ svc, home := testService(t) // auth off
+ cfgPath := ConfigPath("claude-code", home)
+ if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ // A legacy mcpproxy entry written by a pre-Spec-078 build: url carries the key.
+ legacy := `{"mcpServers":{"mcpproxy":{"type":"http","url":"http://127.0.0.1:8080/mcp?apikey=old-leaked-key"}}}`
+ if err := os.WriteFile(cfgPath, []byte(legacy), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // The legacy entry is recognized as connected (matching anchors on base URL).
+ st, err := svc.GetStatus("claude-code")
+ if err != nil {
+ t.Fatalf("GetStatus: %v", err)
+ }
+ if !st.Connected {
+ t.Fatal("legacy ?apikey= entry must be recognized as connected")
+ }
+
+ // Reconnect (force) upgrades it in place to the clean, keyless shape.
+ if _, err := svc.Connect("claude-code", "mcpproxy", true); err != nil {
+ t.Fatalf("Connect: %v", err)
+ }
+ raw, _ := os.ReadFile(cfgPath)
+ if strings.Contains(string(raw), "old-leaked-key") || strings.Contains(string(raw), "apikey") {
+ t.Fatalf("upgrade must drop the leaked key, got: %s", raw)
+ }
+ var data map[string]interface{}
+ if err := json.Unmarshal(raw, &data); err != nil {
+ t.Fatal(err)
+ }
+ servers := data["mcpServers"].(map[string]interface{})
+ if len(servers) != 1 {
+ t.Fatalf("expected a single mcpproxy entry (no duplicate), got %d: %v", len(servers), servers)
+ }
+
+ // Disconnect must still find and remove the (now clean) entry.
+ res, err := svc.Disconnect("claude-code", "mcpproxy")
+ if err != nil {
+ t.Fatalf("Disconnect: %v", err)
+ }
+ if res.Action != "removed" {
+ t.Fatalf("expected removed, got %s", res.Action)
+ }
+}
diff --git a/internal/httpapi/connect_test.go b/internal/httpapi/connect_test.go
index f9d971671..b04bb3f27 100644
--- a/internal/httpapi/connect_test.go
+++ b/internal/httpapi/connect_test.go
@@ -298,7 +298,8 @@ func TestHandleConnectClientPreview_MaskedNoSideEffects(t *testing.T) {
require.NoError(t, os.WriteFile(cfgPath, original, 0o644))
const secret = "rest-secret-key-9999"
- svc := connect.NewServiceWithHome("127.0.0.1:8080", secret, home)
+ // require_mcp_auth on so a credential is written (masked in the preview).
+ svc := connect.NewServiceWithHome("127.0.0.1:8080", secret, home).WithRequireMCPAuth(true)
srv.SetConnectService(svc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/connect/claude-code/preview", http.NoBody)
@@ -323,6 +324,9 @@ func TestHandleConnectClientPreview_MaskedNoSideEffects(t *testing.T) {
assert.False(t, resp.Data.EntryExists)
assert.Equal(t, "accessible", resp.Data.AccessState)
assert.Contains(t, resp.Data.EntryText, "http://127.0.0.1:8080/mcp")
+ // claude-code carries the masked credential in a header, not the URL.
+ assert.Contains(t, resp.Data.EntryText, "X-API-Key")
+ assert.NotContains(t, resp.Data.EntryText, "apikey=")
// No write, no backup.
after, err := os.ReadFile(cfgPath)
diff --git a/internal/server/server.go b/internal/server/server.go
index bad4b8573..4a2d18129 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -1927,7 +1927,7 @@ func (s *Server) startCustomHTTPServer(ctx context.Context, streamableServer *se
})
// Wire client connect service
if cfg := s.runtime.Config(); cfg != nil {
- connectSvc := connect.NewService(cfg.Listen, cfg.APIKey)
+ connectSvc := connect.NewService(cfg.Listen, cfg.APIKey).WithRequireMCPAuth(cfg.RequireMCPAuth)
httpAPIServer.SetConnectService(connectSvc)
// Spec 046: wire the onboarding-funnel provider on the telemetry
From b05b202eeaa68d594870b7b49c0218a085f082d7 Mon Sep 17 00:00:00 2001
From: Algis Dumbris
Date: Thu, 2 Jul 2026 17:47:33 +0300
Subject: [PATCH 4/7] feat(web): carrier-agnostic credential copy in the
connect preview
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Follows the connect security fix: the preview panel's masked-credential line
now reads "This entry includes your API key (shown masked)…" instead of "The
URL includes…", since with require_mcp_auth on the credential is carried in an
X-API-Key header (or the mcp-remote --header bridge arg) for most clients and
only falls back to the ?apikey= URL query for Codex. The line already renders
only when contains_api_key is true, which the backend now sets only when
require_mcp_auth is on — so the default (keyless) connect shows no credential
line at all.
Adds a frontend test for the default keyless case: contains_api_key=false ⇒
no credential line, and the previewed entry shows neither an apikey query nor
a mask. Full frontend suite: 33 files, 245 tests pass; vue-tsc clean.
Related #793
Co-Authored-By: Claude Fable 5
---
frontend/src/components/ConnectModal.vue | 2 +-
frontend/src/components/OnboardingWizard.vue | 2 +-
frontend/tests/unit/connect-preview.spec.ts | 21 ++++++++++++++++++++
3 files changed, 23 insertions(+), 2 deletions(-)
diff --git a/frontend/src/components/ConnectModal.vue b/frontend/src/components/ConnectModal.vue
index cff77861d..582b69be9 100644
--- a/frontend/src/components/ConnectModal.vue
+++ b/frontend/src/components/ConnectModal.vue
@@ -168,7 +168,7 @@
:data-test="`connect-preview-apikey-${client.id}`"
class="text-[11px] opacity-60 leading-relaxed"
>
- The URL includes your API key (shown masked). The real key is written into the config so the client can authenticate.
+ This entry includes your API key (shown masked). The real key is written into the config so the client can authenticate.