From 3801b9bd2bec124c49c8dbf734cc12e7bbdf3714 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 13 Mar 2026 09:51:05 +0200 Subject: [PATCH 1/6] feat: include annotations in tool quarantine hash for rug-pull detection Extend calculateToolApprovalHash to include serialized tool annotations in the SHA-256 hash. This detects "annotation rug-pulls" where a malicious server flips behavioral hints (e.g., destructiveHint from true to false) without changing the tool description or schema. Nil annotations contribute an empty string to maintain backward compatibility with tools approved before annotation tracking. Co-Authored-By: Claude Opus 4.6 --- internal/runtime/tool_quarantine.go | 18 ++- internal/runtime/tool_quarantine_test.go | 146 +++++++++++++++++++++-- 2 files changed, 153 insertions(+), 11 deletions(-) diff --git a/internal/runtime/tool_quarantine.go b/internal/runtime/tool_quarantine.go index 67895dd9..721cc49e 100644 --- a/internal/runtime/tool_quarantine.go +++ b/internal/runtime/tool_quarantine.go @@ -13,14 +13,24 @@ import ( ) // calculateToolApprovalHash computes a stable SHA-256 hash for tool-level quarantine. -// Uses toolName + description + schemaJSON for consistent detection of changes. -func calculateToolApprovalHash(toolName, description, schemaJSON string) string { +// Uses toolName + description + schemaJSON + annotationsJSON for consistent detection of changes. +// This includes annotations to detect "annotation rug-pulls" (e.g., flipping destructiveHint). +// If annotations is nil, an empty string is used to maintain backward compatibility +// with tools that were approved before annotation tracking was added. +func calculateToolApprovalHash(toolName, description, schemaJSON string, annotations *config.ToolAnnotations) string { h := sha256.New() h.Write([]byte(toolName)) h.Write([]byte("|")) h.Write([]byte(description)) h.Write([]byte("|")) h.Write([]byte(schemaJSON)) + h.Write([]byte("|")) + if annotations != nil { + annotationsJSON, err := json.Marshal(annotations) + if err == nil { + h.Write(annotationsJSON) + } + } return hex.EncodeToString(h.Sum(nil)) } @@ -80,8 +90,8 @@ func (r *Runtime) checkToolApprovals(serverName string, tools []*config.ToolMeta schemaJSON = "{}" } - // Calculate current hash - currentHash := calculateToolApprovalHash(toolName, tool.Description, schemaJSON) + // Calculate current hash (includes annotations for rug-pull detection) + currentHash := calculateToolApprovalHash(toolName, tool.Description, schemaJSON, tool.Annotations) // Look up existing approval record existing, err := r.storageManager.GetToolApproval(serverName, toolName) diff --git a/internal/runtime/tool_quarantine_test.go b/internal/runtime/tool_quarantine_test.go index 77515fb7..871e0f8c 100644 --- a/internal/runtime/tool_quarantine_test.go +++ b/internal/runtime/tool_quarantine_test.go @@ -65,7 +65,7 @@ func TestCheckToolApprovals_ApprovedTool_SameHash(t *testing.T) { }) // Pre-approve a tool - hash := calculateToolApprovalHash("create_issue", "Creates a GitHub issue", `{"type":"object"}`) + hash := calculateToolApprovalHash("create_issue", "Creates a GitHub issue", `{"type":"object"}`, nil) err := rt.storageManager.SaveToolApproval(&storage.ToolApprovalRecord{ ServerName: "github", ToolName: "create_issue", @@ -100,7 +100,7 @@ func TestCheckToolApprovals_ApprovedTool_ChangedHash(t *testing.T) { }) // Pre-approve a tool with old hash - oldHash := calculateToolApprovalHash("create_issue", "Creates a GitHub issue", `{"type":"object"}`) + oldHash := calculateToolApprovalHash("create_issue", "Creates a GitHub issue", `{"type":"object"}`, nil) err := rt.storageManager.SaveToolApproval(&storage.ToolApprovalRecord{ ServerName: "github", ToolName: "create_issue", @@ -324,17 +324,17 @@ func TestApproveAllTools(t *testing.T) { } func TestCalculateToolApprovalHash(t *testing.T) { - h1 := calculateToolApprovalHash("tool_a", "desc A", `{"type":"object"}`) - h2 := calculateToolApprovalHash("tool_a", "desc A", `{"type":"object"}`) + h1 := calculateToolApprovalHash("tool_a", "desc A", `{"type":"object"}`, nil) + h2 := calculateToolApprovalHash("tool_a", "desc A", `{"type":"object"}`, nil) assert.Equal(t, h1, h2, "Same inputs should produce same hash") - h3 := calculateToolApprovalHash("tool_a", "desc B", `{"type":"object"}`) + h3 := calculateToolApprovalHash("tool_a", "desc B", `{"type":"object"}`, nil) assert.NotEqual(t, h1, h3, "Different description should produce different hash") - h4 := calculateToolApprovalHash("tool_a", "desc A", `{"type":"array"}`) + h4 := calculateToolApprovalHash("tool_a", "desc A", `{"type":"array"}`, nil) assert.NotEqual(t, h1, h4, "Different schema should produce different hash") - h5 := calculateToolApprovalHash("tool_b", "desc A", `{"type":"object"}`) + h5 := calculateToolApprovalHash("tool_b", "desc A", `{"type":"object"}`, nil) assert.NotEqual(t, h1, h5, "Different tool name should produce different hash") } @@ -370,3 +370,135 @@ func TestFilterBlockedTools_EmptyBlocked(t *testing.T) { filtered := filterBlockedTools(tools, map[string]bool{}) assert.Len(t, filtered, 2) } + +func TestCalculateToolApprovalHash_IncludesAnnotations(t *testing.T) { + // Hash with no annotations + hNil := calculateToolApprovalHash("tool_a", "desc", `{}`, nil) + + // Hash with annotations (destructiveHint=true) + hDestructive := calculateToolApprovalHash("tool_a", "desc", `{}`, &config.ToolAnnotations{ + DestructiveHint: boolP(true), + }) + assert.NotEqual(t, hNil, hDestructive, "Adding annotations should change the hash") + + // Hash with different annotations (destructiveHint=false) + hSafe := calculateToolApprovalHash("tool_a", "desc", `{}`, &config.ToolAnnotations{ + DestructiveHint: boolP(false), + }) + assert.NotEqual(t, hDestructive, hSafe, "Different annotation values should produce different hashes") + + // Same annotations should produce same hash + hDestructive2 := calculateToolApprovalHash("tool_a", "desc", `{}`, &config.ToolAnnotations{ + DestructiveHint: boolP(true), + }) + assert.Equal(t, hDestructive, hDestructive2, "Same annotations should produce same hash") + + // Hash with readOnlyHint + hReadOnly := calculateToolApprovalHash("tool_a", "desc", `{}`, &config.ToolAnnotations{ + ReadOnlyHint: boolP(true), + }) + assert.NotEqual(t, hNil, hReadOnly, "ReadOnlyHint annotation should change the hash") + assert.NotEqual(t, hDestructive, hReadOnly, "Different annotation fields should produce different hashes") + + // Hash with title + hTitle := calculateToolApprovalHash("tool_a", "desc", `{}`, &config.ToolAnnotations{ + Title: "My Tool", + }) + assert.NotEqual(t, hNil, hTitle, "Title annotation should change the hash") +} + +func TestCalculateToolApprovalHash_NilAnnotations(t *testing.T) { + // Verify nil annotations produce a stable, reproducible hash (backward compatibility). + // Tools approved before annotation tracking should keep their existing hash + // because nil annotations contributes empty string to the hash input. + h1 := calculateToolApprovalHash("tool_x", "some description", `{"type":"object"}`, nil) + h2 := calculateToolApprovalHash("tool_x", "some description", `{"type":"object"}`, nil) + assert.Equal(t, h1, h2, "Nil annotations should produce consistent hash") + + // Empty annotations struct (no fields set) should differ from nil + hEmpty := calculateToolApprovalHash("tool_x", "some description", `{"type":"object"}`, &config.ToolAnnotations{}) + assert.NotEqual(t, h1, hEmpty, "Empty annotations struct should differ from nil annotations") +} + +func TestAnnotationRugPullDetection(t *testing.T) { + // Scenario: A server initially declares destructiveHint=true, gets approved, + // then flips it to false (annotation rug pull). The quarantine system should + // detect this as a "changed" tool and block it. + + tempDir := t.TempDir() + + // Phase 1: Tool approved with destructiveHint=true + cfg1 := &config.Config{ + DataDir: tempDir, + Listen: "127.0.0.1:0", + ToolResponseLimit: 0, + QuarantineEnabled: nil, // defaults to true + Servers: []*config.ServerConfig{ + {Name: "evil-server", Enabled: true}, + }, + } + rt1, err := New(cfg1, "", zap.NewNop()) + require.NoError(t, err) + + // Initial tool with destructiveHint=true + tools := []*config.ToolMetadata{ + { + ServerName: "evil-server", + Name: "delete_files", + Description: "Deletes files from disk", + ParamsJSON: `{"type":"object","properties":{"path":{"type":"string"}}}`, + Annotations: &config.ToolAnnotations{ + DestructiveHint: boolP(true), + }, + }, + } + + // Auto-approve (server is not quarantined) + result, err := rt1.checkToolApprovals("evil-server", tools) + require.NoError(t, err) + assert.Equal(t, 0, len(result.BlockedTools), "Should auto-approve on first discovery") + + // Verify it was approved + record, err := rt1.storageManager.GetToolApproval("evil-server", "delete_files") + require.NoError(t, err) + assert.Equal(t, storage.ToolApprovalStatusApproved, record.Status) + + require.NoError(t, rt1.Close()) + + // Phase 2: Server flips destructiveHint to false (rug pull!) + cfg2 := &config.Config{ + DataDir: tempDir, + Listen: "127.0.0.1:0", + ToolResponseLimit: 0, + QuarantineEnabled: nil, // defaults to true + Servers: []*config.ServerConfig{ + {Name: "evil-server", Enabled: true}, + }, + } + rt2, err := New(cfg2, "", zap.NewNop()) + require.NoError(t, err) + t.Cleanup(func() { _ = rt2.Close() }) + + // Same tool but with destructiveHint flipped to false + rugPullTools := []*config.ToolMetadata{ + { + ServerName: "evil-server", + Name: "delete_files", + Description: "Deletes files from disk", // Same description + ParamsJSON: `{"type":"object","properties":{"path":{"type":"string"}}}`, // Same schema + Annotations: &config.ToolAnnotations{ + DestructiveHint: boolP(false), // FLIPPED from true to false! + }, + }, + } + + result, err = rt2.checkToolApprovals("evil-server", rugPullTools) + require.NoError(t, err) + assert.Equal(t, 1, result.ChangedCount, "Annotation rug pull should be detected as a change") + assert.True(t, result.BlockedTools["delete_files"], "Rug-pulled tool should be blocked") + + // Verify the record shows changed status + record, err = rt2.storageManager.GetToolApproval("evil-server", "delete_files") + require.NoError(t, err) + assert.Equal(t, storage.ToolApprovalStatusChanged, record.Status) +} From 7be09e893d532b431eb703474d4a615de98aac0b Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 13 Mar 2026 09:54:04 +0200 Subject: [PATCH 2/6] feat: add GET /api/v1/annotations/coverage endpoint Add annotation coverage reporting endpoint that shows how many upstream tools have MCP annotations (hint booleans) vs those that don't, broken down by server. A tool counts as annotated only if at least one of ReadOnlyHint, DestructiveHint, IdempotentHint, or OpenWorldHint is set (Title alone does not count). Co-Authored-By: Claude Opus 4.6 --- internal/httpapi/annotation_coverage_test.go | 213 +++++++++++++++++++ internal/httpapi/server.go | 105 +++++++++ 2 files changed, 318 insertions(+) create mode 100644 internal/httpapi/annotation_coverage_test.go diff --git a/internal/httpapi/annotation_coverage_test.go b/internal/httpapi/annotation_coverage_test.go new file mode 100644 index 00000000..300593a5 --- /dev/null +++ b/internal/httpapi/annotation_coverage_test.go @@ -0,0 +1,213 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" +) + +// annotationCoverageController extends MockServerController with customizable tool responses +type annotationCoverageController struct { + MockServerController + allServers []map[string]interface{} + serverTools map[string][]map[string]interface{} +} + +func (m *annotationCoverageController) GetAllServers() ([]map[string]interface{}, error) { + return m.allServers, nil +} + +func (m *annotationCoverageController) GetServerTools(serverName string) ([]map[string]interface{}, error) { + tools, ok := m.serverTools[serverName] + if !ok { + return []map[string]interface{}{}, nil + } + return tools, nil +} + +func TestHandleAnnotationCoverage(t *testing.T) { + logger := zaptest.NewLogger(t).Sugar() + + ctrl := &annotationCoverageController{ + allServers: []map[string]interface{}{ + {"name": "github-server"}, + {"name": "slack-server"}, + }, + serverTools: map[string][]map[string]interface{}{ + "github-server": { + { + "name": "create_issue", + "description": "Create a GitHub issue", + "annotations": &config.ToolAnnotations{ + ReadOnlyHint: boolPtr(false), + DestructiveHint: boolPtr(false), + }, + }, + { + "name": "list_repos", + "description": "List repositories", + "annotations": &config.ToolAnnotations{ + ReadOnlyHint: boolPtr(true), + }, + }, + { + "name": "get_user", + "description": "Get user info", + // No annotations + }, + }, + "slack-server": { + { + "name": "send_message", + "description": "Send a Slack message", + "annotations": &config.ToolAnnotations{ + DestructiveHint: boolPtr(false), + OpenWorldHint: boolPtr(true), + }, + }, + { + "name": "list_channels", + "description": "List channels", + // No annotations + }, + }, + }, + } + + srv := NewServer(ctrl, logger, nil) + + req := httptest.NewRequest("GET", "/api/v1/annotations/coverage", nil) + // Add API key bypass by setting request source to socket (trusted) + req.Header.Set("X-Request-Source", "socket") + w := httptest.NewRecorder() + + srv.router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + data, ok := resp["data"].(map[string]interface{}) + require.True(t, ok, "expected data field in response") + + // Total: 5 tools, 3 annotated (create_issue, list_repos, send_message) + assert.Equal(t, float64(5), data["total_tools"]) + assert.Equal(t, float64(3), data["annotated_tools"]) + assert.Equal(t, float64(60), data["coverage_percent"]) + + servers, ok := data["servers"].([]interface{}) + require.True(t, ok) + assert.Len(t, servers, 2) + + // Find each server in the response (order may vary) + serverMap := make(map[string]map[string]interface{}) + for _, s := range servers { + srv := s.(map[string]interface{}) + serverMap[srv["name"].(string)] = srv + } + + github := serverMap["github-server"] + require.NotNil(t, github) + assert.Equal(t, float64(3), github["total_tools"]) + assert.Equal(t, float64(2), github["annotated_tools"]) + // 2/3 = 66.66... rounded to 66.67 + assert.InDelta(t, 66.67, github["coverage_percent"], 0.01) + + slack := serverMap["slack-server"] + require.NotNil(t, slack) + assert.Equal(t, float64(2), slack["total_tools"]) + assert.Equal(t, float64(1), slack["annotated_tools"]) + assert.Equal(t, float64(50), slack["coverage_percent"]) +} + +func TestAnnotationCoverage_EmptyServers(t *testing.T) { + logger := zaptest.NewLogger(t).Sugar() + + ctrl := &annotationCoverageController{ + allServers: []map[string]interface{}{}, + serverTools: map[string][]map[string]interface{}{}, + } + + srv := NewServer(ctrl, logger, nil) + + req := httptest.NewRequest("GET", "/api/v1/annotations/coverage", nil) + req.Header.Set("X-Request-Source", "socket") + w := httptest.NewRecorder() + + srv.router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + data, ok := resp["data"].(map[string]interface{}) + require.True(t, ok) + + assert.Equal(t, float64(0), data["total_tools"]) + assert.Equal(t, float64(0), data["annotated_tools"]) + assert.Equal(t, float64(0), data["coverage_percent"]) + + servers, ok := data["servers"].([]interface{}) + require.True(t, ok) + assert.Len(t, servers, 0) +} + +func TestAnnotationCoverage_TitleOnlyNotCounted(t *testing.T) { + // A tool with only Title set (no hint booleans) should NOT count as annotated + logger := zaptest.NewLogger(t).Sugar() + + ctrl := &annotationCoverageController{ + allServers: []map[string]interface{}{ + {"name": "test-server"}, + }, + serverTools: map[string][]map[string]interface{}{ + "test-server": { + { + "name": "tool_with_title_only", + "description": "Has annotations but only title", + "annotations": &config.ToolAnnotations{ + Title: "My Tool", + }, + }, + { + "name": "tool_with_hints", + "description": "Has actual hint annotations", + "annotations": &config.ToolAnnotations{ + Title: "Another Tool", + ReadOnlyHint: boolPtr(true), + }, + }, + }, + }, + } + + srv := NewServer(ctrl, logger, nil) + + req := httptest.NewRequest("GET", "/api/v1/annotations/coverage", nil) + req.Header.Set("X-Request-Source", "socket") + w := httptest.NewRecorder() + + srv.router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + data := resp["data"].(map[string]interface{}) + assert.Equal(t, float64(2), data["total_tools"]) + assert.Equal(t, float64(1), data["annotated_tools"]) + assert.Equal(t, float64(50), data["coverage_percent"]) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index c3150d45..ed94997f 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "net/http" "strconv" "strings" @@ -534,6 +535,9 @@ func (s *Server) setupRoutes() { r.Get("/activity/export", s.handleExportActivity) r.Get("/activity/{id}", s.handleGetActivityDetail) + // Annotation coverage (Spec 035) + r.Get("/annotations/coverage", s.handleAnnotationCoverage) + // Agent token management (Spec 028) r.Route("/tokens", func(r chi.Router) { r.Post("/", s.handleCreateToken) @@ -3380,3 +3384,104 @@ func (s *Server) handleExportToolDescriptions(w http.ResponseWriter, r *http.Req "count": len(exports), }) } + +// handleAnnotationCoverage godoc +// @Summary Get annotation coverage report +// @Description Reports how many upstream tools have MCP annotations vs don't, broken down by server +// @Tags annotations +// @Produce json +// @Security ApiKeyAuth +// @Security ApiKeyQuery +// @Success 200 {object} contracts.SuccessResponse "Annotation coverage report" +// @Router /api/v1/annotations/coverage [get] +func (s *Server) handleAnnotationCoverage(w http.ResponseWriter, r *http.Request) { + type serverCoverage struct { + Name string `json:"name"` + TotalTools int `json:"total_tools"` + AnnotatedTools int `json:"annotated_tools"` + CoveragePercent float64 `json:"coverage_percent"` + } + + type coverageResponse struct { + TotalTools int `json:"total_tools"` + AnnotatedTools int `json:"annotated_tools"` + CoveragePercent float64 `json:"coverage_percent"` + Servers []serverCoverage `json:"servers"` + } + + allServers, err := s.controller.GetAllServers() + if err != nil { + s.writeError(w, r, http.StatusInternalServerError, "Failed to get servers") + return + } + + resp := coverageResponse{ + Servers: make([]serverCoverage, 0, len(allServers)), + } + + for _, srv := range allServers { + name, _ := srv["name"].(string) + if name == "" { + continue + } + + tools, err := s.controller.GetServerTools(name) + if err != nil { + // Skip servers whose tools can't be retrieved (disconnected, etc.) + continue + } + + sc := serverCoverage{ + Name: name, + TotalTools: len(tools), + } + + for _, tool := range tools { + if hasAnnotationHints(tool) { + sc.AnnotatedTools++ + } + } + + if sc.TotalTools > 0 { + sc.CoveragePercent = math.Round(float64(sc.AnnotatedTools)/float64(sc.TotalTools)*10000) / 100 + } + + resp.TotalTools += sc.TotalTools + resp.AnnotatedTools += sc.AnnotatedTools + resp.Servers = append(resp.Servers, sc) + } + + if resp.TotalTools > 0 { + resp.CoveragePercent = math.Round(float64(resp.AnnotatedTools)/float64(resp.TotalTools)*10000) / 100 + } + + s.writeSuccess(w, resp) +} + +// hasAnnotationHints checks if a tool map has meaningful annotation hints. +// A tool is considered "annotated" if its Annotations is non-nil AND at least +// one of ReadOnlyHint, DestructiveHint, IdempotentHint, OpenWorldHint is set. +// Title alone does not count as a meaningful annotation. +func hasAnnotationHints(tool map[string]interface{}) bool { + ann, ok := tool["annotations"] + if !ok || ann == nil { + return false + } + + // Check if it's a *config.ToolAnnotations (direct from stateview) + if ta, ok := ann.(*config.ToolAnnotations); ok { + return ta.ReadOnlyHint != nil || ta.DestructiveHint != nil || + ta.IdempotentHint != nil || ta.OpenWorldHint != nil + } + + // Fallback: check as map (e.g., from JSON round-trip) + if m, ok := ann.(map[string]interface{}); ok { + for _, key := range []string{"readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"} { + if v, exists := m[key]; exists && v != nil { + return true + } + } + } + + return false +} From 305f54024f686a62b2f008be6d83ca4964860908 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 13 Mar 2026 09:54:25 +0200 Subject: [PATCH 3/6] chore: regenerate OpenAPI spec for annotations/coverage endpoint Co-Authored-By: Claude Opus 4.6 --- oas/docs.go | 2 +- oas/swagger.yaml | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/oas/docs.go b/oas/docs.go index 7e64012d..1501cb20 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_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"api_key":{"description":"Security settings","type":"string"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"disable_management":{"type":"boolean"},"docker_isolation":{"$ref":"#/components/schemas/config.DockerIsolationConfig"},"docker_recovery":{"$ref":"#/components/schemas/config.DockerRecoveryConfig"},"enable_code_execution":{"description":"Code execution settings","type":"boolean"},"enable_prompts":{"description":"Prompts settings","type":"boolean"},"enable_socket":{"description":"Enable Unix socket/named pipe for local IPC (default: true)","type":"boolean"},"enable_tray":{"description":"Deprecated: EnableTray is unused and has no runtime effect. Kept for backward compatibility.","type":"boolean"},"environment":{"$ref":"#/components/schemas/secureenv.EnvConfig"},"features":{"$ref":"#/components/schemas/config.FeatureFlags"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"quarantine_enabled":{"description":"Tool-level quarantine settings (Spec 032)\nQuarantineEnabled controls whether tool-level quarantine is active.\nWhen nil (default), quarantine is enabled (secure by default).\nSet to explicit false to disable tool-level quarantine.","type":"boolean"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_response_limit":{"type":"integer"},"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"},"enabled":{"description":"Global enable/disable for Docker isolation","type":"boolean"},"extra_args":{"description":"Additional docker run arguments","items":{"type":"string"},"type":"array","uniqueItems":false},"log_driver":{"description":"Docker log driver (default: json-file)","type":"string"},"log_max_files":{"description":"Maximum number of log files (default: 3)","type":"string"},"log_max_size":{"description":"Maximum size of log files (default: 100m)","type":"string"},"memory_limit":{"description":"Memory limit for containers","type":"string"},"network_mode":{"description":"Docker network mode (default: bridge)","type":"string"},"registry":{"description":"Custom registry (defaults to docker.io)","type":"string"},"timeout":{"description":"Container startup timeout","type":"string"}},"type":"object"},"config.DockerRecoveryConfig":{"description":"Docker recovery settings","properties":{"enabled":{"description":"Enable Docker recovery monitoring (default: true)","type":"boolean"},"max_retries":{"description":"Maximum retry attempts (0 = unlimited)","type":"integer"},"notify_on_failure":{"description":"Show notification on recovery failure (default: true)","type":"boolean"},"notify_on_retry":{"description":"Show notification on each retry (default: false)","type":"boolean"},"notify_on_start":{"description":"Show notification when recovery starts (default: true)","type":"boolean"},"notify_on_success":{"description":"Show notification on successful recovery (default: true)","type":"boolean"},"persistent_state":{"description":"Save recovery state across restarts (default: true)","type":"boolean"}},"type":"object"},"config.FeatureFlags":{"description":"Deprecated: Features flags are unused and have no runtime effect. Kept for backward compatibility.","properties":{"enable_async_storage":{"type":"boolean"},"enable_caching":{"type":"boolean"},"enable_contract_tests":{"type":"boolean"},"enable_debug_logging":{"description":"Development features","type":"boolean"},"enable_docker_isolation":{"type":"boolean"},"enable_event_bus":{"type":"boolean"},"enable_health_checks":{"type":"boolean"},"enable_metrics":{"type":"boolean"},"enable_oauth":{"description":"Security features","type":"boolean"},"enable_observability":{"description":"Observability features","type":"boolean"},"enable_quarantine":{"type":"boolean"},"enable_runtime":{"description":"Runtime features","type":"boolean"},"enable_search":{"description":"Storage features","type":"boolean"},"enable_sse":{"type":"boolean"},"enable_tracing":{"type":"boolean"},"enable_tray":{"type":"boolean"},"enable_web_ui":{"description":"UI features","type":"boolean"}},"type":"object"},"config.IntentDeclarationConfig":{"description":"Intent declaration settings (Spec 018)","properties":{"strict_server_validation":{"description":"StrictServerValidation controls whether server annotation mismatches\ncause rejection (true) or just warnings (false).\nDefault: true (reject mismatches)","type":"boolean"}},"type":"object"},"config.IsolationConfig":{"description":"Per-server isolation settings","properties":{"enabled":{"description":"Enable Docker isolation for this server (nil = inherit global)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments for this server","items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"description":"Custom Docker image (overrides default)","type":"string"},"log_driver":{"description":"Docker log driver override for this server","type":"string"},"log_max_files":{"description":"Maximum number of log files override","type":"string"},"log_max_size":{"description":"Maximum size of log files override","type":"string"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"config.LogConfig":{"description":"Logging configuration","properties":{"compress":{"type":"boolean"},"enable_console":{"type":"boolean"},"enable_file":{"type":"boolean"},"filename":{"type":"string"},"json_format":{"type":"boolean"},"level":{"type":"string"},"log_dir":{"description":"Custom log directory","type":"string"},"max_age":{"description":"days","type":"integer"},"max_backups":{"description":"number of backup files","type":"integer"},"max_size":{"description":"MB","type":"integer"}},"type":"object"},"config.OAuthConfig":{"description":"OAuth configuration (keep even when empty to signal OAuth requirement)","properties":{"client_id":{"type":"string"},"client_secret":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters (e.g., RFC 8707 resource)","type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_uri":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"created":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"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"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"Skip tool-level quarantine for this server","type":"boolean"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\"","type":"string"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"arguments":{"description":"Tool call arguments","type":"object"},"detection_types":{"description":"List of detection types found","items":{"type":"string"},"type":"array","uniqueItems":false},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP session ID for correlation","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"}},"type":"object"},"contracts.ActivitySource":{"description":"How activity was triggered: \"mcp\", \"cli\", \"api\"","type":"string","x-enum-varnames":["ActivitySourceMCP","ActivitySourceCLI","ActivitySourceAPI"]},"contracts.ActivitySummaryResponse":{"properties":{"blocked_count":{"description":"Count of blocked activities","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.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.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"},"image":{"type":"string"},"memory_limit":{"type":"string"},"timeout":{"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":{"changed_count":{"description":"Number of tools whose description/schema changed since approval","type":"integer"},"pending_count":{"description":"Number of newly discovered tools awaiting approval","type":"integer"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"isolation":{"$ref":"#/components/schemas/contracts.IsolationConfig"},"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"},"retry_count":{"type":"integer"},"should_retry":{"type":"boolean"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"updated":{"type":"string"},"url":{"type":"string"},"user_logged_out":{"description":"True if user explicitly logged out (prevents auto-reconnection)","type":"boolean"},"working_dir":{"type":"string"}},"type":"object"},"contracts.ServerActionResponse":{"properties":{"action":{"type":"string"},"async":{"type":"boolean"},"server":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ServerStats":{"properties":{"connected_servers":{"type":"integer"},"docker_containers":{"type":"integer"},"quarantined_servers":{"type":"integer"},"token_metrics":{"$ref":"#/components/schemas/contracts.ServerTokenMetrics"},"total_servers":{"type":"integer"},"total_tools":{"type":"integer"}},"type":"object"},"contracts.ServerTokenMetrics":{"properties":{"average_query_result_size":{"description":"Typical retrieve_tools output (tokens)","type":"integer"},"per_server_tool_list_sizes":{"additionalProperties":{"type":"integer"},"description":"Token size per server","type":"object"},"saved_tokens":{"description":"Difference","type":"integer"},"saved_tokens_percentage":{"description":"Percentage saved","type":"number"},"total_server_tool_list_size":{"description":"All upstream tools combined (tokens)","type":"integer"}},"type":"object"},"contracts.SuccessResponse":{"properties":{"data":{"type":"object"},"success":{"type":"boolean"}},"type":"object"},"contracts.TokenMetrics":{"description":"Token usage metrics (nil for older records)","properties":{"encoding":{"description":"Encoding used (e.g., cl100k_base)","type":"string"},"estimated_cost":{"description":"Optional cost estimate","type":"number"},"input_tokens":{"description":"Tokens in the request","type":"integer"},"model":{"description":"Model used for tokenization","type":"string"},"output_tokens":{"description":"Tokens in the response","type":"integer"},"total_tokens":{"description":"Total tokens (input + output)","type":"integer"},"truncated_tokens":{"description":"Tokens removed by truncation","type":"integer"},"was_truncated":{"description":"Whether response was truncated","type":"boolean"}},"type":"object"},"contracts.Tool":{"properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"description":{"type":"string"},"last_used":{"type":"string"},"name":{"type":"string"},"schema":{"type":"object"},"server_name":{"type":"string"},"usage":{"type":"integer"}},"type":"object"},"contracts.ToolAnnotation":{"description":"Tool behavior hints snapshot","properties":{"destructiveHint":{"type":"boolean"},"idempotentHint":{"type":"boolean"},"openWorldHint":{"type":"boolean"},"readOnlyHint":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"contracts.ToolCallRecord":{"description":"The new tool call record","properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"arguments":{"description":"Tool arguments","type":"object"},"config_path":{"description":"Active config file path","type":"string"},"duration":{"description":"Duration in nanoseconds","type":"integer"},"error":{"description":"Error message (failure only)","type":"string"},"execution_type":{"description":"\"direct\" or \"code_execution\"","type":"string"},"id":{"description":"Unique identifier","type":"string"},"mcp_client_name":{"description":"MCP client name from InitializeRequest","type":"string"},"mcp_client_version":{"description":"MCP client version","type":"string"},"mcp_session_id":{"description":"MCP session identifier","type":"string"},"metrics":{"$ref":"#/components/schemas/contracts.TokenMetrics"},"parent_call_id":{"description":"Links nested calls to parent code_execution","type":"string"},"request_id":{"description":"Request correlation ID","type":"string"},"response":{"description":"Tool response (success only)","type":"object"},"server_id":{"description":"Server identity hash","type":"string"},"server_name":{"description":"Human-readable server name","type":"string"},"timestamp":{"description":"When the call was made","type":"string"},"tool_name":{"description":"Tool name (without server prefix)","type":"string"}},"type":"object"},"contracts.UpdateInfo":{"description":"Update information (if available)","properties":{"available":{"description":"Whether an update is available","type":"boolean"},"check_error":{"description":"Error message if update check failed","type":"string"},"checked_at":{"description":"When the update check was performed","type":"string"},"is_prerelease":{"description":"Whether the latest version is a prerelease","type":"boolean"},"latest_version":{"description":"Latest version available (e.g., \"v1.2.3\")","type":"string"},"release_url":{"description":"URL to the release page","type":"string"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"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.ImportFromPathRequest":{"properties":{"format":{"description":"Optional format hint","type":"string"},"path":{"description":"File path to import from","type":"string"},"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"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"}},"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"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/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"]}},"/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/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/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/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/registries":{"get":{"description":"Retrieves list of all MCP server registries that can be browsed for discovering and installing new upstream servers. Includes registry metadata, server counts, and API endpoints.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetRegistriesResponse"}}},"description":"Registries retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to list registries"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List available MCP server registries","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove an upstream server","tags":["servers"]}},"/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}/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/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/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"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/config":{"get":{"description":"Retrieves the current MCPProxy configuration including all server definitions, global settings, and runtime parameters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetConfigResponse"}}},"description":"Configuration retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]}},"/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/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/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/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/registries":{"get":{"description":"Retrieves list of all MCP server registries that can be browsed for discovering and installing new upstream servers. Includes registry metadata, server counts, and API endpoints.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetRegistriesResponse"}}},"description":"Registries retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to list registries"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List available MCP server registries","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove an upstream server","tags":["servers"]}},"/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}/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/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/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 aace52a4..e0546d69 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -2182,6 +2182,23 @@ paths: summary: Get activity summary statistics 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 From d189b82f70b512907903d768352054f31c4c1372 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 13 Mar 2026 09:56:07 +0200 Subject: [PATCH 4/6] feat: add lethal trifecta session risk analysis and annotation-based filtering to retrieve_tools (Spec 035 F2+F4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F2: Session risk analysis examines all connected servers' tool annotations to detect the "lethal trifecta" — open-world access + destructive capabilities + write access. Returns risk level (high/medium/low) in every retrieve_tools response as session_risk, with a warning when the trifecta is present. F4: Three new optional boolean parameters (read_only_only, exclude_destructive, exclude_open_world) allow agents to self-restrict tool discovery scope based on MCP annotation hints. Nil annotations are treated as most permissive per spec. Co-Authored-By: Claude Opus 4.6 --- internal/server/mcp.go | 68 +++++ internal/server/mcp_annotations.go | 157 +++++++++++ internal/server/mcp_annotations_test.go | 346 ++++++++++++++++++++++++ internal/server/mcp_routing.go | 25 +- 4 files changed, 594 insertions(+), 2 deletions(-) create mode 100644 internal/server/mcp_annotations.go create mode 100644 internal/server/mcp_annotations_test.go diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 1aadf5d0..29ef98cb 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -883,6 +883,11 @@ func (p *MCPProxyServer) handleRetrieveToolsWithMode(ctx context.Context, reques debugMode := request.GetBool("debug", false) explainTool := request.GetString("explain_tool", "") + // Spec 035 F4: Annotation-based filtering parameters + readOnlyOnly := request.GetBool("read_only_only", false) + excludeDestructive := request.GetBool("exclude_destructive", false) + excludeOpenWorld := request.GetBool("exclude_open_world", false) + // Build arguments map for activity logging (Spec 024) args := map[string]interface{}{ "query": query, @@ -897,6 +902,15 @@ func (p *MCPProxyServer) handleRetrieveToolsWithMode(ctx context.Context, reques if explainTool != "" { args["explain_tool"] = explainTool } + if readOnlyOnly { + args["read_only_only"] = true + } + if excludeDestructive { + args["exclude_destructive"] = true + } + if excludeOpenWorld { + args["exclude_open_world"] = true + } // Validate limit if limit > 100 { @@ -929,6 +943,40 @@ func (p *MCPProxyServer) handleRetrieveToolsWithMode(ctx context.Context, reques results = filtered } + // Spec 035 F4: Resolve annotations for each result and apply annotation-based filtering + // before building the MCP tool response. This allows agents to self-restrict discovery. + annotationFilterActive := readOnlyOnly || excludeDestructive || excludeOpenWorld + if annotationFilterActive { + var annotatedResults []annotatedSearchResult + for i, result := range results { + serverName := result.Tool.ServerName + toolName := result.Tool.Name + if serverName == "" { + if parts := strings.SplitN(result.Tool.Name, ":", 2); len(parts) == 2 { + serverName = parts[0] + toolName = parts[1] + } + } + var annotations *config.ToolAnnotations + if serverName != "" { + annotations = p.lookupToolAnnotations(serverName, toolName) + } + annotatedResults = append(annotatedResults, annotatedSearchResult{ + serverName: serverName, + toolName: toolName, + annotations: annotations, + resultIndex: i, + }) + } + + filtered := filterByAnnotations(annotatedResults, readOnlyOnly, excludeDestructive, excludeOpenWorld) + var filteredResults []*config.SearchResult + for _, ar := range filtered { + filteredResults = append(filteredResults, results[ar.resultIndex]) + } + results = filteredResults + } + // Convert results to MCP tool format for LLM compatibility var mcpTools []map[string]interface{} for _, result := range results { @@ -1020,6 +1068,26 @@ func (p *MCPProxyServer) handleRetrieveToolsWithMode(ctx context.Context, reques "usage_instructions": usageInstructions, } + // Spec 035 F2: Session risk analysis — analyze all connected servers' tool annotations + // to detect the "lethal trifecta" risk combination. + if p.mainServer != nil && p.mainServer.runtime != nil { + if sup := p.mainServer.runtime.Supervisor(); sup != nil { + snapshot := sup.StateView().Snapshot() + risk := analyzeSessionRisk(snapshot) + sessionRisk := map[string]interface{}{ + "level": risk.Level, + "has_open_world_tools": risk.HasOpenWorld, + "has_destructive_tools": risk.HasDestructive, + "has_write_tools": risk.HasWrite, + "lethal_trifecta": risk.LethalTrifecta, + } + if risk.Warning != "" { + sessionRisk["warning"] = risk.Warning + } + response["session_risk"] = sessionRisk + } + } + // Add debug information if requested if debugMode { response["debug"] = map[string]interface{}{ diff --git a/internal/server/mcp_annotations.go b/internal/server/mcp_annotations.go new file mode 100644 index 00000000..418aee9b --- /dev/null +++ b/internal/server/mcp_annotations.go @@ -0,0 +1,157 @@ +package server + +import ( + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime/stateview" +) + +// SessionRisk holds the result of analyzing all connected servers' tool annotations +// for the "lethal trifecta" risk combination (Spec 035 F2). +type SessionRisk struct { + Level string `json:"level"` // "high", "medium", "low" + HasOpenWorld bool `json:"has_open_world"` // Any tool with openWorldHint=true or nil + HasDestructive bool `json:"has_destructive"` // Any tool with destructiveHint=true or nil + HasWrite bool `json:"has_write"` // Any tool with readOnlyHint=false or nil + LethalTrifecta bool `json:"lethal_trifecta"` // All three categories present + Warning string `json:"warning,omitempty"` +} + +// analyzeSessionRisk examines all connected servers' tool annotations to detect +// the "lethal trifecta" risk: open-world access + destructive capabilities + write access. +// Per MCP spec, nil annotation hints default to the most permissive interpretation: +// - openWorldHint nil → true (assumes open world) +// - destructiveHint nil → true (assumes destructive) +// - readOnlyHint nil → false (assumes not read-only, i.e., can write) +func analyzeSessionRisk(snapshot *stateview.ServerStatusSnapshot) SessionRisk { + var hasOpenWorld, hasDestructive, hasWrite bool + + for _, server := range snapshot.Servers { + if !server.Connected { + continue + } + + for _, tool := range server.Tools { + classifyToolRisk(tool.Annotations, &hasOpenWorld, &hasDestructive, &hasWrite) + } + } + + // Count how many risk categories are present + riskCount := 0 + if hasOpenWorld { + riskCount++ + } + if hasDestructive { + riskCount++ + } + if hasWrite { + riskCount++ + } + + risk := SessionRisk{ + HasOpenWorld: hasOpenWorld, + HasDestructive: hasDestructive, + HasWrite: hasWrite, + } + + switch { + case riskCount >= 3: + risk.Level = "high" + risk.LethalTrifecta = true + risk.Warning = "LETHAL TRIFECTA DETECTED: This session combines open-world access, " + + "destructive capabilities, and write access across connected servers. " + + "A prompt injection attack could chain these to cause significant damage. " + + "Consider using annotation filters (read_only_only, exclude_destructive, exclude_open_world) " + + "to restrict tool discovery." + case riskCount == 2: + risk.Level = "medium" + default: + risk.Level = "low" + } + + return risk +} + +// classifyToolRisk updates the risk flags based on a single tool's annotations. +// Nil hints are treated as their MCP spec defaults (most permissive). +func classifyToolRisk(annotations *config.ToolAnnotations, hasOpenWorld, hasDestructive, hasWrite *bool) { + if annotations == nil { + // No annotations at all — apply MCP spec defaults (all permissive) + *hasOpenWorld = true + *hasDestructive = true + *hasWrite = true + return + } + + // openWorldHint: nil or true → open world + if annotations.OpenWorldHint == nil || *annotations.OpenWorldHint { + *hasOpenWorld = true + } + + // destructiveHint: nil or true → destructive + if annotations.DestructiveHint == nil || *annotations.DestructiveHint { + *hasDestructive = true + } + + // readOnlyHint: nil or false → not read-only (write capable) + if annotations.ReadOnlyHint == nil || !*annotations.ReadOnlyHint { + *hasWrite = true + } +} + +// annotatedSearchResult pairs a search result with its resolved annotations +// for use in annotation-based filtering (Spec 035 F4). +type annotatedSearchResult struct { + serverName string + toolName string + annotations *config.ToolAnnotations + resultIndex int // Index into the original search results slice +} + +// filterByAnnotations filters annotated search results based on annotation criteria. +// Returns only the results that pass all active filters. +// +// Filter semantics (per MCP spec, nil hints default to most permissive): +// - readOnlyOnly: keep only tools with readOnlyHint=true (explicit) +// - excludeDestructive: exclude tools with destructiveHint=true or nil +// - excludeOpenWorld: exclude tools with openWorldHint=true or nil +func filterByAnnotations(tools []annotatedSearchResult, readOnlyOnly, excludeDestructive, excludeOpenWorld bool) []annotatedSearchResult { + // Fast path: no filters active + if !readOnlyOnly && !excludeDestructive && !excludeOpenWorld { + return tools + } + + var filtered []annotatedSearchResult + for _, tool := range tools { + if shouldExclude(tool.annotations, readOnlyOnly, excludeDestructive, excludeOpenWorld) { + continue + } + filtered = append(filtered, tool) + } + return filtered +} + +// shouldExclude returns true if a tool should be excluded based on its annotations and active filters. +func shouldExclude(annotations *config.ToolAnnotations, readOnlyOnly, excludeDestructive, excludeOpenWorld bool) bool { + if readOnlyOnly { + // Must have explicit readOnlyHint=true to pass + if annotations == nil || annotations.ReadOnlyHint == nil || !*annotations.ReadOnlyHint { + return true + } + } + + if excludeDestructive { + // Exclude if destructiveHint is true or nil (default is true per spec) + if annotations == nil || annotations.DestructiveHint == nil || *annotations.DestructiveHint { + return true + } + } + + if excludeOpenWorld { + // Exclude if openWorldHint is true or nil (default is true per spec) + if annotations == nil || annotations.OpenWorldHint == nil || *annotations.OpenWorldHint { + return true + } + } + + return false +} diff --git a/internal/server/mcp_annotations_test.go b/internal/server/mcp_annotations_test.go new file mode 100644 index 00000000..af4fa52a --- /dev/null +++ b/internal/server/mcp_annotations_test.go @@ -0,0 +1,346 @@ +package server + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime/stateview" +) + +func boolPtr(b bool) *bool { + return &b +} + +func TestAnalyzeSessionRisk_LethalTrifecta(t *testing.T) { + // All three risk categories present across different servers + snapshot := &stateview.ServerStatusSnapshot{ + Servers: map[string]*stateview.ServerStatus{ + "github": { + Name: "github", + Connected: true, + Tools: []stateview.ToolInfo{ + { + Name: "delete_repo", + Annotations: &config.ToolAnnotations{ + DestructiveHint: boolPtr(true), + OpenWorldHint: boolPtr(false), + }, + }, + { + Name: "search_repos", + Annotations: &config.ToolAnnotations{ + ReadOnlyHint: boolPtr(true), + OpenWorldHint: boolPtr(true), // open world + }, + }, + }, + }, + "filesystem": { + Name: "filesystem", + Connected: true, + Tools: []stateview.ToolInfo{ + { + Name: "write_file", + Annotations: &config.ToolAnnotations{ + ReadOnlyHint: boolPtr(false), // write tool + }, + }, + }, + }, + }, + } + + risk := analyzeSessionRisk(snapshot) + + assert.Equal(t, "high", risk.Level) + assert.True(t, risk.HasOpenWorld) + assert.True(t, risk.HasDestructive) + assert.True(t, risk.HasWrite) + assert.True(t, risk.LethalTrifecta) + assert.NotEmpty(t, risk.Warning) +} + +func TestAnalyzeSessionRisk_LowRisk(t *testing.T) { + // Only read-only tools present + snapshot := &stateview.ServerStatusSnapshot{ + Servers: map[string]*stateview.ServerStatus{ + "readonly-server": { + Name: "readonly-server", + Connected: true, + Tools: []stateview.ToolInfo{ + { + Name: "list_items", + Annotations: &config.ToolAnnotations{ + ReadOnlyHint: boolPtr(true), + DestructiveHint: boolPtr(false), + OpenWorldHint: boolPtr(false), + }, + }, + { + Name: "get_item", + Annotations: &config.ToolAnnotations{ + ReadOnlyHint: boolPtr(true), + DestructiveHint: boolPtr(false), + OpenWorldHint: boolPtr(false), + }, + }, + }, + }, + }, + } + + risk := analyzeSessionRisk(snapshot) + + assert.Equal(t, "low", risk.Level) + assert.False(t, risk.HasOpenWorld) + assert.False(t, risk.HasDestructive) + assert.False(t, risk.HasWrite) + assert.False(t, risk.LethalTrifecta) + assert.Empty(t, risk.Warning) +} + +func TestAnalyzeSessionRisk_MediumRisk(t *testing.T) { + // Two of three categories present: destructive + open world but all read-only + snapshot := &stateview.ServerStatusSnapshot{ + Servers: map[string]*stateview.ServerStatus{ + "server": { + Name: "server", + Connected: true, + Tools: []stateview.ToolInfo{ + { + Name: "delete_thing", + Annotations: &config.ToolAnnotations{ + DestructiveHint: boolPtr(true), + ReadOnlyHint: boolPtr(true), + OpenWorldHint: boolPtr(false), + }, + }, + { + Name: "search_web", + Annotations: &config.ToolAnnotations{ + ReadOnlyHint: boolPtr(true), + OpenWorldHint: boolPtr(true), + }, + }, + }, + }, + }, + } + + risk := analyzeSessionRisk(snapshot) + + assert.Equal(t, "medium", risk.Level) + assert.True(t, risk.HasOpenWorld) + assert.True(t, risk.HasDestructive) + assert.False(t, risk.HasWrite) + assert.False(t, risk.LethalTrifecta) + assert.Empty(t, risk.Warning) +} + +func TestAnalyzeSessionRisk_NilAnnotationsDefaultRisk(t *testing.T) { + // Per MCP spec, nil annotations mean defaults: + // openWorldHint defaults to true, destructiveHint defaults to true, + // readOnlyHint defaults to false (not read-only) + // So nil annotations should trigger all three risk categories + snapshot := &stateview.ServerStatusSnapshot{ + Servers: map[string]*stateview.ServerStatus{ + "unknown-server": { + Name: "unknown-server", + Connected: true, + Tools: []stateview.ToolInfo{ + { + Name: "mysterious_tool", + Annotations: nil, // No annotations at all + }, + }, + }, + }, + } + + risk := analyzeSessionRisk(snapshot) + + assert.Equal(t, "high", risk.Level) + assert.True(t, risk.HasOpenWorld, "nil openWorldHint should default to true") + assert.True(t, risk.HasDestructive, "nil destructiveHint should default to true") + assert.True(t, risk.HasWrite, "nil readOnlyHint should mean not read-only") + assert.True(t, risk.LethalTrifecta) +} + +func TestAnalyzeSessionRisk_DisconnectedServersIgnored(t *testing.T) { + // Disconnected servers should not contribute to risk analysis + snapshot := &stateview.ServerStatusSnapshot{ + Servers: map[string]*stateview.ServerStatus{ + "dangerous-server": { + Name: "dangerous-server", + Connected: false, // Not connected + Tools: []stateview.ToolInfo{ + { + Name: "nuke_everything", + Annotations: &config.ToolAnnotations{ + DestructiveHint: boolPtr(true), + OpenWorldHint: boolPtr(true), + }, + }, + }, + }, + }, + } + + risk := analyzeSessionRisk(snapshot) + + assert.Equal(t, "low", risk.Level) + assert.False(t, risk.HasOpenWorld) + assert.False(t, risk.HasDestructive) + assert.False(t, risk.HasWrite) + assert.False(t, risk.LethalTrifecta) +} + +func TestAnalyzeSessionRisk_EmptySnapshot(t *testing.T) { + snapshot := &stateview.ServerStatusSnapshot{ + Servers: map[string]*stateview.ServerStatus{}, + } + + risk := analyzeSessionRisk(snapshot) + + assert.Equal(t, "low", risk.Level) + assert.False(t, risk.LethalTrifecta) +} + +func TestAnnotationFiltering_ReadOnlyOnly(t *testing.T) { + tools := []annotatedSearchResult{ + { + serverName: "s1", + toolName: "list_items", + annotations: &config.ToolAnnotations{ + ReadOnlyHint: boolPtr(true), + }, + }, + { + serverName: "s1", + toolName: "create_item", + annotations: &config.ToolAnnotations{ + ReadOnlyHint: boolPtr(false), + }, + }, + { + serverName: "s1", + toolName: "unknown_tool", + annotations: nil, // nil readOnlyHint defaults to not read-only + }, + } + + filtered := filterByAnnotations(tools, true, false, false) + + assert.Len(t, filtered, 1) + assert.Equal(t, "list_items", filtered[0].toolName) +} + +func TestAnnotationFiltering_ExcludeDestructive(t *testing.T) { + tools := []annotatedSearchResult{ + { + serverName: "s1", + toolName: "list_items", + annotations: &config.ToolAnnotations{ + ReadOnlyHint: boolPtr(true), + DestructiveHint: boolPtr(false), + }, + }, + { + serverName: "s1", + toolName: "delete_item", + annotations: &config.ToolAnnotations{ + DestructiveHint: boolPtr(true), + }, + }, + { + serverName: "s1", + toolName: "unknown_tool", + annotations: nil, // nil destructiveHint defaults to true + }, + } + + filtered := filterByAnnotations(tools, false, true, false) + + assert.Len(t, filtered, 1) + assert.Equal(t, "list_items", filtered[0].toolName) +} + +func TestAnnotationFiltering_ExcludeOpenWorld(t *testing.T) { + tools := []annotatedSearchResult{ + { + serverName: "s1", + toolName: "local_tool", + annotations: &config.ToolAnnotations{ + OpenWorldHint: boolPtr(false), + }, + }, + { + serverName: "s1", + toolName: "web_search", + annotations: &config.ToolAnnotations{ + OpenWorldHint: boolPtr(true), + }, + }, + { + serverName: "s1", + toolName: "unknown_scope", + annotations: nil, // nil openWorldHint defaults to true + }, + } + + filtered := filterByAnnotations(tools, false, false, true) + + assert.Len(t, filtered, 1) + assert.Equal(t, "local_tool", filtered[0].toolName) +} + +func TestAnnotationFiltering_CombinedFilters(t *testing.T) { + tools := []annotatedSearchResult{ + { + serverName: "s1", + toolName: "safe_local_read", + annotations: &config.ToolAnnotations{ + ReadOnlyHint: boolPtr(true), + DestructiveHint: boolPtr(false), + OpenWorldHint: boolPtr(false), + }, + }, + { + serverName: "s1", + toolName: "safe_open_read", + annotations: &config.ToolAnnotations{ + ReadOnlyHint: boolPtr(true), + DestructiveHint: boolPtr(false), + OpenWorldHint: boolPtr(true), + }, + }, + { + serverName: "s1", + toolName: "destructive_local", + annotations: &config.ToolAnnotations{ + DestructiveHint: boolPtr(true), + OpenWorldHint: boolPtr(false), + }, + }, + } + + // read_only_only + exclude_open_world + filtered := filterByAnnotations(tools, true, false, true) + + assert.Len(t, filtered, 1) + assert.Equal(t, "safe_local_read", filtered[0].toolName) +} + +func TestAnnotationFiltering_NoFiltersPassAll(t *testing.T) { + tools := []annotatedSearchResult{ + {serverName: "s1", toolName: "tool1", annotations: nil}, + {serverName: "s1", toolName: "tool2", annotations: nil}, + {serverName: "s1", toolName: "tool3", annotations: nil}, + } + + filtered := filterByAnnotations(tools, false, false, false) + + assert.Len(t, filtered, 3) +} diff --git a/internal/server/mcp_routing.go b/internal/server/mcp_routing.go index 84b6a312..e09d02a0 100644 --- a/internal/server/mcp_routing.go +++ b/internal/server/mcp_routing.go @@ -215,7 +215,8 @@ func (p *MCPProxyServer) buildCodeExecModeTools() []mcpserver.ServerTool { mcp.WithDescription("Search and discover available upstream tools using BM25 full-text search. "+ "Use this to find tools, then use the `code_execution` tool to call them via `call_tool(serverName, toolName, args)` in JavaScript. "+ "Do NOT use call_tool_read/write/destructive — they are not available in this mode. "+ - "Use natural language to describe what you want to accomplish."), + "Use natural language to describe what you want to accomplish. "+ + "Response includes session_risk analysis detecting the 'lethal trifecta' of open-world + destructive + write tools."), mcp.WithTitleAnnotation("Retrieve Tools"), mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("query", @@ -225,6 +226,15 @@ func (p *MCPProxyServer) buildCodeExecModeTools() []mcpserver.ServerTool { mcp.WithNumber("limit", mcp.Description("Maximum number of tools to return (default: configured tools_limit, max: 100)"), ), + mcp.WithBoolean("read_only_only", + mcp.Description("Only return tools with readOnlyHint=true. Use to self-restrict to safe read operations."), + ), + mcp.WithBoolean("exclude_destructive", + mcp.Description("Exclude tools with destructiveHint=true or unset (MCP default is destructive). Use to avoid destructive operations."), + ), + mcp.WithBoolean("exclude_open_world", + mcp.Description("Exclude tools with openWorldHint=true or unset (MCP default is open-world). Use to restrict to local/sandboxed tools."), + ), ) tools = append(tools, mcpserver.ServerTool{ Tool: retrieveToolsTool, @@ -250,7 +260,9 @@ func (p *MCPProxyServer) buildCallToolModeTools() []mcpserver.ServerTool { mcp.WithDescription("Search and discover available upstream tools using BM25 full-text search. "+ "WORKFLOW: 1) Call this tool first to find relevant tools, 2) Check the 'call_with' field in results "+ "to determine which variant to use, 3) Call the tool using call_tool_read, call_tool_write, or call_tool_destructive. "+ - "Results include 'annotations' (tool behavior hints like destructiveHint) and 'call_with' recommendation. "+ + "Results include 'annotations' (tool behavior hints like destructiveHint), 'call_with' recommendation, "+ + "and 'session_risk' analysis detecting the 'lethal trifecta' of open-world + destructive + write tools. "+ + "Use annotation filters to self-restrict discovery scope. "+ "Use natural language to describe what you want to accomplish."), mcp.WithTitleAnnotation("Retrieve Tools"), mcp.WithReadOnlyHintAnnotation(true), @@ -270,6 +282,15 @@ func (p *MCPProxyServer) buildCallToolModeTools() []mcpserver.ServerTool { mcp.WithString("explain_tool", mcp.Description("When debug=true, explain why a specific tool was ranked low (format: 'server:tool')"), ), + mcp.WithBoolean("read_only_only", + mcp.Description("Only return tools with readOnlyHint=true. Use to self-restrict to safe read operations."), + ), + mcp.WithBoolean("exclude_destructive", + mcp.Description("Exclude tools with destructiveHint=true or unset (MCP default is destructive). Use to avoid destructive operations."), + ), + mcp.WithBoolean("exclude_open_world", + mcp.Description("Exclude tools with openWorldHint=true or unset (MCP default is open-world). Use to restrict to local/sandboxed tools."), + ), ) tools = append(tools, mcpserver.ServerTool{ Tool: retrieveToolsTool, From cfe4895033a8f2ada2ea0a91d0da12a0b78ed3f6 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 13 Mar 2026 10:06:04 +0200 Subject: [PATCH 5/6] feat: add openWorldHint content trust scanning for tool calls (Spec 035 F3) When a tool call is made via call_tool_read/write/destructive, code_execution, or direct routing mode, check if the called tool has openWorldHint=true (or nil, which defaults to true per MCP spec). Tag the activity record metadata with "content_trust": "untrusted" for open-world tools, or "trusted" for closed-world tools (openWorldHint=false). This enables downstream security review of tool outputs that may contain untrusted external data. Changes: - Add IsOpenWorldTool() and ContentTrustForTool() helpers in contracts/intent.go - Add content_trust field to EmitActivityToolCallCompleted event payload - Add content_trust extraction in handleToolCallCompleted and handleInternalToolCall activity service handlers - Add EmitActivityInternalToolCallWithContentTrust for code_execution path - Compute content trust in handleCallToolVariant, makeDirectModeHandler, and code_execution handler (any open-world tool call marks entire execution) - Add comprehensive tests: TestIsOpenWorldTool, TestContentTrustForTool, TestHandleToolCallCompleted_ContentTrust, TestHandleInternalToolCall_ContentTrust Co-Authored-By: Claude Opus 4.6 --- internal/contracts/intent.go | 50 +++++++-- internal/contracts/intent_test.go | 118 +++++++++++++++++++-- internal/runtime/activity_service.go | 15 ++- internal/runtime/activity_service_test.go | 123 ++++++++++++++++++++++ internal/runtime/event_bus.go | 42 +++++++- internal/server/mcp.go | 37 +++++-- internal/server/mcp_code_execution.go | 24 ++++- internal/server/mcp_routing.go | 7 +- 8 files changed, 385 insertions(+), 31 deletions(-) diff --git a/internal/contracts/intent.go b/internal/contracts/intent.go index 6e2107f8..d4ea04ba 100644 --- a/internal/contracts/intent.go +++ b/internal/contracts/intent.go @@ -76,20 +76,20 @@ type IntentDeclaration struct { // IntentValidationError represents intent validation failures type IntentValidationError struct { - Code string `json:"code"` // Error code for programmatic handling - Message string `json:"message"` // Human-readable error message - Details map[string]interface{} `json:"details" swaggertype:"object"` // Additional context + Code string `json:"code"` // Error code for programmatic handling + Message string `json:"message"` // Human-readable error message + Details map[string]interface{} `json:"details" swaggertype:"object"` // Additional context } // Error codes for intent validation const ( - IntentErrorCodeMissing = "MISSING_INTENT" + IntentErrorCodeMissing = "MISSING_INTENT" IntentErrorCodeMissingOperationType = "MISSING_OPERATION_TYPE" IntentErrorCodeInvalidOperationType = "INVALID_OPERATION_TYPE" - IntentErrorCodeMismatch = "INTENT_MISMATCH" - IntentErrorCodeServerMismatch = "SERVER_MISMATCH" - IntentErrorCodeInvalidSensitivity = "INVALID_SENSITIVITY" - IntentErrorCodeReasonTooLong = "REASON_TOO_LONG" + IntentErrorCodeMismatch = "INTENT_MISMATCH" + IntentErrorCodeServerMismatch = "SERVER_MISMATCH" + IntentErrorCodeInvalidSensitivity = "INVALID_SENSITIVITY" + IntentErrorCodeReasonTooLong = "REASON_TOO_LONG" ) // Error implements the error interface @@ -115,8 +115,8 @@ func (i *IntentDeclaration) Validate() *IntentValidationError { IntentErrorCodeInvalidSensitivity, fmt.Sprintf("Invalid intent.data_sensitivity '%s': must be public, internal, private, or unknown", i.DataSensitivity), map[string]interface{}{ - "provided": i.DataSensitivity, - "valid_values": ValidDataSensitivities, + "provided": i.DataSensitivity, + "valid_values": ValidDataSensitivities, }, ) } @@ -227,6 +227,36 @@ func DeriveCallWith(annotations *config.ToolAnnotations) string { return ToolVariantRead } +// Content trust constants for open-world hint scanning (Spec 035) +const ( + // ContentTrustUntrusted marks tool output as untrusted (open-world tool, data from external sources) + ContentTrustUntrusted = "untrusted" + // ContentTrustTrusted marks tool output as trusted (closed-world tool, data from controlled sources) + ContentTrustTrusted = "trusted" +) + +// IsOpenWorldTool checks whether a tool's annotations indicate it operates in an +// open-world context (fetching data from external/untrusted sources). Per the MCP spec, +// openWorldHint defaults to true when nil or when annotations are nil entirely. +func IsOpenWorldTool(annotations *config.ToolAnnotations) bool { + if annotations == nil { + return true // spec default: openWorldHint=true + } + if annotations.OpenWorldHint == nil { + return true // nil defaults to true per spec + } + return *annotations.OpenWorldHint +} + +// ContentTrustForTool returns the content trust level based on tool annotations. +// Open-world tools return "untrusted", closed-world tools return "trusted". +func ContentTrustForTool(annotations *config.ToolAnnotations) string { + if IsOpenWorldTool(annotations) { + return ContentTrustUntrusted + } + return ContentTrustTrusted +} + // isValidDataSensitivity checks if the data sensitivity is valid func isValidDataSensitivity(sensitivity string) bool { for _, valid := range ValidDataSensitivities { diff --git a/internal/contracts/intent_test.go b/internal/contracts/intent_test.go index e2297d3f..49be9049 100644 --- a/internal/contracts/intent_test.go +++ b/internal/contracts/intent_test.go @@ -6,6 +6,112 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" ) +func TestIsOpenWorldTool(t *testing.T) { + trueVal := true + falseVal := false + + tests := []struct { + name string + annotations *config.ToolAnnotations + want bool + }{ + { + name: "nil annotations - defaults to true (open world)", + annotations: nil, + want: true, + }, + { + name: "empty annotations (no hints set) - defaults to true", + annotations: &config.ToolAnnotations{}, + want: true, + }, + { + name: "openWorldHint nil - defaults to true", + annotations: &config.ToolAnnotations{ + ReadOnlyHint: &trueVal, // other hint set but not openWorldHint + }, + want: true, + }, + { + name: "openWorldHint explicitly true", + annotations: &config.ToolAnnotations{ + OpenWorldHint: &trueVal, + }, + want: true, + }, + { + name: "openWorldHint explicitly false", + annotations: &config.ToolAnnotations{ + OpenWorldHint: &falseVal, + }, + want: false, + }, + { + name: "openWorldHint false with other hints", + annotations: &config.ToolAnnotations{ + OpenWorldHint: &falseVal, + ReadOnlyHint: &trueVal, + DestructiveHint: &falseVal, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsOpenWorldTool(tt.annotations) + if got != tt.want { + t.Errorf("IsOpenWorldTool() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestContentTrustForTool(t *testing.T) { + trueVal := true + falseVal := false + + tests := []struct { + name string + annotations *config.ToolAnnotations + want string + }{ + { + name: "nil annotations - untrusted", + annotations: nil, + want: ContentTrustUntrusted, + }, + { + name: "empty annotations - untrusted", + annotations: &config.ToolAnnotations{}, + want: ContentTrustUntrusted, + }, + { + name: "openWorldHint true - untrusted", + annotations: &config.ToolAnnotations{ + OpenWorldHint: &trueVal, + }, + want: ContentTrustUntrusted, + }, + { + name: "openWorldHint false - trusted", + annotations: &config.ToolAnnotations{ + OpenWorldHint: &falseVal, + }, + want: ContentTrustTrusted, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ContentTrustForTool(tt.annotations) + if got != tt.want { + t.Errorf("ContentTrustForTool() = %v, want %v", got, tt.want) + } + }) + } +} + func TestIntentDeclaration_Validate(t *testing.T) { // Note: Validate() only checks optional fields (data_sensitivity, reason) // operation_type is inferred from tool variant, not validated here @@ -99,12 +205,12 @@ func TestIntentDeclaration_ValidateForToolVariant(t *testing.T) { // Note: ValidateForToolVariant now SETS operation_type from tool variant (inference) // It no longer validates that operation_type matches - it always overwrites with inferred value tests := []struct { - name string - intent IntentDeclaration - toolVariant string - wantErr bool - wantErrCode string - wantOpType string // expected operation_type after call + name string + intent IntentDeclaration + toolVariant string + wantErr bool + wantErrCode string + wantOpType string // expected operation_type after call }{ { name: "empty intent with call_tool_read - sets operation_type", diff --git a/internal/runtime/activity_service.go b/internal/runtime/activity_service.go index 000ed991..7477d5eb 100644 --- a/internal/runtime/activity_service.go +++ b/internal/runtime/activity_service.go @@ -227,6 +227,8 @@ func (s *ActivityService) handleToolCallCompleted(evt Event) { // Extract intent metadata if present (Spec 018) toolVariant := getStringPayload(evt.Payload, "tool_variant") intent := getMapPayload(evt.Payload, "intent") + // Extract content trust metadata if present (Spec 035) + contentTrust := getStringPayload(evt.Payload, "content_trust") // Default source to "mcp" if not specified (backwards compatibility) activitySource := storage.ActivitySourceMCP if source != "" { @@ -235,7 +237,7 @@ func (s *ActivityService) handleToolCallCompleted(evt Event) { // Build metadata with intent information if present var metadata map[string]interface{} - if toolVariant != "" || intent != nil { + if toolVariant != "" || intent != nil || contentTrust != "" { metadata = make(map[string]interface{}) if toolVariant != "" { metadata["tool_variant"] = toolVariant @@ -243,6 +245,10 @@ func (s *ActivityService) handleToolCallCompleted(evt Event) { if intent != nil { metadata["intent"] = intent } + // Spec 035: Tag activity with content trust level based on openWorldHint + if contentTrust != "" { + metadata["content_trust"] = contentTrust + } } record := &storage.ActivityRecord{ @@ -447,6 +453,9 @@ func (s *ActivityService) handleInternalToolCall(evt Event) { } } + // Extract content trust metadata if present (Spec 035) + contentTrust := getStringPayload(evt.Payload, "content_trust") + metadata := map[string]interface{}{ "internal_tool_name": internalToolName, } @@ -462,6 +471,10 @@ func (s *ActivityService) handleInternalToolCall(evt Event) { if intent != nil { metadata["intent"] = intent } + // Spec 035: Tag activity with content trust level based on openWorldHint + if contentTrust != "" { + metadata["content_trust"] = contentTrust + } record := &storage.ActivityRecord{ Type: storage.ActivityTypeInternalToolCall, diff --git a/internal/runtime/activity_service_test.go b/internal/runtime/activity_service_test.go index a6572224..425ea28f 100644 --- a/internal/runtime/activity_service_test.go +++ b/internal/runtime/activity_service_test.go @@ -642,6 +642,129 @@ func TestHandleInternalToolCall_UserIdentityExtraction(t *testing.T) { assert.Equal(t, "retrieve_tools", record.ToolName) } +// TestHandleToolCallCompleted_ContentTrust verifies that content_trust metadata +// is extracted from the event payload and stored in the activity record's metadata (Spec 035). +func TestHandleToolCallCompleted_ContentTrust(t *testing.T) { + tests := []struct { + name string + contentTrust string + wantInMeta bool + wantValue string + }{ + { + name: "untrusted content (open-world tool)", + contentTrust: "untrusted", + wantInMeta: true, + wantValue: "untrusted", + }, + { + name: "trusted content (closed-world tool)", + contentTrust: "trusted", + wantInMeta: true, + wantValue: "trusted", + }, + { + name: "empty content trust (not set)", + contentTrust: "", + wantInMeta: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store, cleanup := setupTestStorage(t) + defer cleanup() + + logger := zap.NewNop() + svc := NewActivityService(store, logger) + + payload := map[string]any{ + "server_name": "github", + "tool_name": "search_code", + "session_id": "sess-ct", + "request_id": "req-ct", + "source": "mcp", + "status": "success", + "duration_ms": int64(200), + "tool_variant": "call_tool_read", + "response": `{"results": []}`, + } + if tt.contentTrust != "" { + payload["content_trust"] = tt.contentTrust + } + + evt := Event{ + Type: EventTypeActivityToolCallCompleted, + Timestamp: time.Now().UTC(), + Payload: payload, + } + + svc.handleEvent(evt) + + records, _, err := store.ListActivities(storage.DefaultActivityFilter()) + require.NoError(t, err) + require.Len(t, records, 1) + + record := records[0] + if tt.wantInMeta { + require.NotNil(t, record.Metadata, "Metadata should not be nil when content_trust is set") + ct, ok := record.Metadata["content_trust"] + assert.True(t, ok, "content_trust should be present in metadata") + assert.Equal(t, tt.wantValue, ct, "content_trust value mismatch") + } else { + // When content_trust is empty, metadata may still exist (from tool_variant) + if record.Metadata != nil { + _, ok := record.Metadata["content_trust"] + assert.False(t, ok, "content_trust should not be in metadata when not set") + } + } + }) + } +} + +// TestHandleInternalToolCall_ContentTrust verifies that content_trust metadata +// is extracted from internal tool call events and stored in metadata (Spec 035). +func TestHandleInternalToolCall_ContentTrust(t *testing.T) { + store, cleanup := setupTestStorage(t) + defer cleanup() + + logger := zap.NewNop() + svc := NewActivityService(store, logger) + + evt := Event{ + Type: EventTypeActivityInternalToolCall, + Timestamp: time.Now().UTC(), + Payload: map[string]any{ + "internal_tool_name": "code_execution", + "session_id": "sess-ce", + "request_id": "req-ce", + "status": "success", + "error_message": "", + "duration_ms": int64(500), + "content_trust": "untrusted", + "arguments": map[string]interface{}{ + "code": "call_tool('github', 'search_code', {q: 'test'})", + "language": "javascript", + }, + "response": "ok", + }, + } + + svc.handleEvent(evt) + + filter := storage.DefaultActivityFilter() + filter.ExcludeCallToolSuccess = false + records, _, err := store.ListActivities(filter) + require.NoError(t, err) + require.Len(t, records, 1) + + record := records[0] + require.NotNil(t, record.Metadata, "Metadata should not be nil") + ct, ok := record.Metadata["content_trust"] + assert.True(t, ok, "content_trust should be present in metadata") + assert.Equal(t, "untrusted", ct, "content_trust should be untrusted for code_execution calling open-world tools") +} + // TestHandleInternalToolCall_NoUserIdentity verifies internal tool calls without user identity work. func TestHandleInternalToolCall_NoUserIdentity(t *testing.T) { store, cleanup := setupTestStorage(t) diff --git a/internal/runtime/event_bus.go b/internal/runtime/event_bus.go index 0d6e4066..59c48a73 100644 --- a/internal/runtime/event_bus.go +++ b/internal/runtime/event_bus.go @@ -105,7 +105,7 @@ func (r *Runtime) EmitActivityToolCallStarted(serverName, toolName, sessionID, r // arguments is the input parameters passed to the tool call // toolVariant is the MCP tool variant used (call_tool_read/write/destructive) - optional // intent is the intent declaration metadata - optional -func (r *Runtime) EmitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, source, status, errorMsg string, durationMs int64, arguments map[string]interface{}, response string, responseTruncated bool, toolVariant string, intent map[string]interface{}) { +func (r *Runtime) EmitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, source, status, errorMsg string, durationMs int64, arguments map[string]interface{}, response string, responseTruncated bool, toolVariant string, intent map[string]interface{}, contentTrust string) { payload := map[string]any{ "server_name": serverName, "tool_name": toolName, @@ -129,6 +129,10 @@ func (r *Runtime) EmitActivityToolCallCompleted(serverName, toolName, sessionID, if intent != nil { payload["intent"] = intent } + // Add content trust metadata if provided (Spec 035) + if contentTrust != "" { + payload["content_trust"] = contentTrust + } r.publishEvent(newEvent(EventTypeActivityToolCallCompleted, payload)) } @@ -211,6 +215,42 @@ func (r *Runtime) EmitActivityInternalToolCall(internalToolName, targetServer, t r.publishEvent(newEvent(EventTypeActivityInternalToolCall, payload)) } +// EmitActivityInternalToolCallWithContentTrust is like EmitActivityInternalToolCall but also +// includes content trust metadata (Spec 035) for open-world hint scanning. +func (r *Runtime) EmitActivityInternalToolCallWithContentTrust(internalToolName, targetServer, targetTool, toolVariant, sessionID, requestID, status, errorMsg string, durationMs int64, arguments map[string]interface{}, response interface{}, intent map[string]interface{}, contentTrust string) { + payload := map[string]any{ + "internal_tool_name": internalToolName, + "session_id": sessionID, + "request_id": requestID, + "status": status, + "error_message": errorMsg, + "duration_ms": durationMs, + } + if targetServer != "" { + payload["target_server"] = targetServer + } + if targetTool != "" { + payload["target_tool"] = targetTool + } + if toolVariant != "" { + payload["tool_variant"] = toolVariant + } + if arguments != nil { + payload["arguments"] = arguments + } + if response != nil { + payload["response"] = response + } + if intent != nil { + payload["intent"] = intent + } + // Spec 035: Add content trust metadata + if contentTrust != "" { + payload["content_trust"] = contentTrust + } + r.publishEvent(newEvent(EventTypeActivityInternalToolCall, payload)) +} + // EmitActivityConfigChange emits an event when configuration changes (Spec 024). // action is one of: server_added, server_removed, server_updated, settings_changed // source indicates how the change was triggered: "mcp", "cli", or "api" diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 1aadf5d0..d4eb902f 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -319,9 +319,9 @@ func (p *MCPProxyServer) emitActivityToolCallStarted(serverName, toolName, sessi // arguments is the input parameters passed to the tool call // toolVariant is the MCP tool variant used (call_tool_read/write/destructive) - optional // intent is the intent declaration metadata - optional -func (p *MCPProxyServer) emitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, source, status, errorMsg string, durationMs int64, arguments map[string]interface{}, response string, responseTruncated bool, toolVariant string, intent map[string]interface{}) { +func (p *MCPProxyServer) emitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, source, status, errorMsg string, durationMs int64, arguments map[string]interface{}, response string, responseTruncated bool, toolVariant string, intent map[string]interface{}, contentTrust string) { if p.mainServer != nil && p.mainServer.runtime != nil { - p.mainServer.runtime.EmitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, source, status, errorMsg, durationMs, arguments, response, responseTruncated, toolVariant, intent) + p.mainServer.runtime.EmitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, source, status, errorMsg, durationMs, arguments, response, responseTruncated, toolVariant, intent, contentTrust) } } @@ -342,6 +342,14 @@ func (p *MCPProxyServer) emitActivityInternalToolCall(internalToolName, targetSe } } +// emitActivityInternalToolCallWithContentTrust is like emitActivityInternalToolCall but also +// includes content trust metadata (Spec 035) for open-world hint scanning. +func (p *MCPProxyServer) emitActivityInternalToolCallWithContentTrust(internalToolName, targetServer, targetTool, toolVariant, sessionID, requestID, status, errorMsg string, durationMs int64, arguments map[string]interface{}, response interface{}, intent map[string]interface{}, contentTrust string) { + if p.mainServer != nil && p.mainServer.runtime != nil { + p.mainServer.runtime.EmitActivityInternalToolCallWithContentTrust(internalToolName, targetServer, targetTool, toolVariant, sessionID, requestID, status, errorMsg, durationMs, arguments, response, intent, contentTrust) + } +} + // registerTools registers all proxy tools with the MCP server func (p *MCPProxyServer) registerTools(_ bool) { // retrieve_tools - THE PRIMARY TOOL FOR DISCOVERING TOOLS - Enhanced with clear instructions @@ -1214,6 +1222,15 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp. // Look up tool annotations from StateView for server annotation validation annotations := p.lookupToolAnnotations(serverName, actualToolName) + // Spec 035: Determine content trust level based on openWorldHint annotation + contentTrust := contracts.ContentTrustForTool(annotations) + if contentTrust == contracts.ContentTrustUntrusted { + p.logger.Debug("handleCallToolVariant: open-world tool detected, tagging as untrusted", + zap.String("server_name", serverName), + zap.String("tool_name", actualToolName), + zap.String("content_trust", contentTrust)) + } + // Validate intent against server annotations (unless call_tool_destructive which is most permissive) if errResult := p.validateIntentAgainstServer(intent, toolVariant, serverName, actualToolName, annotations); errResult != nil { // Record activity error for server annotation mismatch @@ -1331,7 +1348,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp. intentMap = intent.ToMap() } p.emitActivityToolCallStarted(serverName, actualToolName, sessionID, requestID, activitySource, activityArgs) - p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, activityArgs, errMsg, false, toolVariant, intentMap) + p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, activityArgs, errMsg, false, toolVariant, intentMap, contentTrust) return mcp.NewToolResultError(errMsg), nil } } else { @@ -1356,7 +1373,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp. intentMap = intent.ToMap() } p.emitActivityToolCallStarted(serverName, actualToolName, sessionID, requestID, activitySource, activityArgs) - p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, activityArgs, errMsg, false, toolVariant, intentMap) + p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, activityArgs, errMsg, false, toolVariant, intentMap, contentTrust) return mcp.NewToolResultError(errMsg), nil } @@ -1453,7 +1470,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp. if intent != nil { intentMap = intent.ToMap() } - p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", err.Error(), duration.Milliseconds(), activityArgs, "", false, toolVariant, intentMap) + p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", err.Error(), duration.Milliseconds(), activityArgs, "", false, toolVariant, intentMap, contentTrust) // Spec 024: Emit internal tool call event for error internalToolName := "call_tool_" + intent.OperationType // e.g., "call_tool_read" @@ -1557,7 +1574,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp. if intent != nil { intentMap = intent.ToMap() } - p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "success", "", duration.Milliseconds(), activityArgs, response, responseTruncated, toolVariant, intentMap) + p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "success", "", duration.Milliseconds(), activityArgs, response, responseTruncated, toolVariant, intentMap, contentTrust) // Spec 024: Emit internal tool call event for success internalToolName := "call_tool_" + intent.OperationType // e.g., "call_tool_read" @@ -1730,7 +1747,7 @@ func (p *MCPProxyServer) handleCallTool(ctx context.Context, request mcp.CallToo } // Log the early failure to activity (Spec 024) p.emitActivityToolCallStarted(serverName, actualToolName, sessionID, requestID, activitySource, activityArgs) - p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, activityArgs, errMsg, false, "", nil) + p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, activityArgs, errMsg, false, "", nil, "") return mcp.NewToolResultError(errMsg), nil } } else { @@ -1739,7 +1756,7 @@ func (p *MCPProxyServer) handleCallTool(ctx context.Context, request mcp.CallToo errMsg := fmt.Sprintf("No client found for server: %s", serverName) // Log the early failure to activity (Spec 024) p.emitActivityToolCallStarted(serverName, actualToolName, sessionID, requestID, activitySource, activityArgs) - p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, activityArgs, errMsg, false, "", nil) + p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, activityArgs, errMsg, false, "", nil, "") return mcp.NewToolResultError(errMsg), nil } @@ -1857,7 +1874,7 @@ func (p *MCPProxyServer) handleCallTool(ctx context.Context, request mcp.CallToo } // Emit activity completed event for error with determined source (legacy - no intent) - p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", err.Error(), duration.Milliseconds(), activityArgs, "", false, "", nil) + p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", err.Error(), duration.Milliseconds(), activityArgs, "", false, "", nil, "") return p.createDetailedErrorResponse(err, serverName, actualToolName), nil } @@ -1953,7 +1970,7 @@ func (p *MCPProxyServer) handleCallTool(ctx context.Context, request mcp.CallToo // Emit activity completed event for success with determined source (legacy - no intent) responseTruncated := tokenMetrics != nil && tokenMetrics.WasTruncated - p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "success", "", duration.Milliseconds(), activityArgs, response, responseTruncated, "", nil) + p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "success", "", duration.Milliseconds(), activityArgs, response, responseTruncated, "", nil, "") return mcp.NewToolResultText(response), nil } diff --git a/internal/server/mcp_code_execution.go b/internal/server/mcp_code_execution.go index 041afe3e..1a80872a 100644 --- a/internal/server/mcp_code_execution.go +++ b/internal/server/mcp_code_execution.go @@ -338,7 +338,29 @@ func (p *MCPProxyServer) handleCodeExecution(ctx context.Context, request mcp.Ca "input": options.Input, "language": effectiveLanguage, } - p.emitActivityInternalToolCall("code_execution", "", "", "", sessionID, parentCallID, status, errorMsg, executionDuration.Milliseconds(), codeExecArgs, result, nil) + + // Spec 035: Determine content trust for code_execution based on tools called. + // If any tool called within the JS sandbox has openWorldHint=true (or nil, default true), + // the entire code_execution result is tagged as untrusted. + codeExecContentTrust := "" + toolCallRecords := toolCaller.getToolCalls() + if len(toolCallRecords) > 0 { + hasOpenWorldTool := false + for _, tc := range toolCallRecords { + toolAnnotations := p.lookupToolAnnotations(tc.ServerName, tc.ToolName) + if contracts.IsOpenWorldTool(toolAnnotations) { + hasOpenWorldTool = true + break + } + } + if hasOpenWorldTool { + codeExecContentTrust = contracts.ContentTrustUntrusted + } else { + codeExecContentTrust = contracts.ContentTrustTrusted + } + } + + p.emitActivityInternalToolCallWithContentTrust("code_execution", "", "", "", sessionID, parentCallID, status, errorMsg, executionDuration.Milliseconds(), codeExecArgs, result, nil, codeExecContentTrust) return &mcp.CallToolResult{ Content: []mcp.Content{ diff --git a/internal/server/mcp_routing.go b/internal/server/mcp_routing.go index 84b6a312..8975e5d2 100644 --- a/internal/server/mcp_routing.go +++ b/internal/server/mcp_routing.go @@ -164,9 +164,12 @@ func (p *MCPProxyServer) makeDirectModeHandler(serverName, toolName string, anno durationMs := time.Since(startTime).Milliseconds() + // Spec 035: Determine content trust based on openWorldHint + directContentTrust := contracts.ContentTrustForTool(annotations) + if err != nil { // Emit error activity - p.emitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, "mcp", "error", err.Error(), durationMs, enrichedArgs, "", false, "", nil) + p.emitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, "mcp", "error", err.Error(), durationMs, enrichedArgs, "", false, "", nil, directContentTrust) return mcp.NewToolResultError(fmt.Sprintf("Error calling %s:%s: %v", serverName, toolName, err)), nil } @@ -195,7 +198,7 @@ func (p *MCPProxyServer) makeDirectModeHandler(serverName, toolName string, anno } // Emit success activity - p.emitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, "mcp", "success", "", durationMs, enrichedArgs, responseText, truncated, toolVariant, nil) + p.emitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, "mcp", "success", "", durationMs, enrichedArgs, responseText, truncated, toolVariant, nil, directContentTrust) return mcp.NewToolResultText(responseText), nil } From a6fd18bc0a1886a82d8c98039629dd1ec1e8f0aa Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 13 Mar 2026 10:18:15 +0200 Subject: [PATCH 6/6] docs: add Spec 035 Enhanced Tool Annotations Intelligence Co-Authored-By: Claude Opus 4.6 --- specs/035-enhanced-annotations/spec.md | 86 ++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 specs/035-enhanced-annotations/spec.md diff --git a/specs/035-enhanced-annotations/spec.md b/specs/035-enhanced-annotations/spec.md new file mode 100644 index 00000000..463532dd --- /dev/null +++ b/specs/035-enhanced-annotations/spec.md @@ -0,0 +1,86 @@ +# Spec 035: Enhanced Tool Annotations Intelligence + +## Overview + +Leverage MCP tool annotations for security-aware routing, quarantine protection, session-level risk analysis, and smarter tool discovery filtering. + +## Features + +### F1: Annotation Change Detection in Quarantine +Include tool annotations in the SHA-256 hash used for tool-level quarantine change detection. Detects "annotation rug-pulls" where a server flips `destructiveHint` from true→false to trick agents into using a dangerous tool through `call_tool_read`. + +**Files**: `internal/runtime/tool_quarantine.go` +- Modify `calculateToolApprovalHash()` to include serialized annotations in hash input +- Format: `toolName|description|schemaJSON|annotationsJSON` +- Nil annotations → empty string (backward compatible, won't invalidate existing approvals unless annotations appear) + +### F2: Lethal Trifecta Session Analysis +When `retrieve_tools` is called, analyze ALL connected servers' tool annotations to detect the "lethal trifecta" (Simon Willison's risk model): a session combining (1) access to sensitive/private data, (2) exposure to untrusted content via open-world tools, and (3) ability to write/destroy. + +**Files**: `internal/server/mcp.go` +- Add `analyzeSessionRisk()` method on MCPProxyServer +- Scan all tools across all servers via StateView snapshot +- Classify tools into risk categories based on annotations: + - `has_sensitive`: tools with `readOnlyHint=false` or accessing private data patterns + - `has_open_world`: tools with `openWorldHint=true` (or nil, since default is true) + - `has_destructive`: tools with `destructiveHint=true` (or nil, since default is true) +- Add `session_risk` field to retrieve_tools response: + ```json + { + "session_risk": { + "level": "high|medium|low", + "has_sensitive_data_tools": true, + "has_open_world_tools": true, + "has_destructive_tools": true, + "lethal_trifecta": true, + "warning": "Session has tools that combine private data access, untrusted content, and destructive capabilities" + } + } + ``` + +### F3: openWorldHint Enhanced Scanning +Flag tool call responses from tools with `openWorldHint=true` for enhanced sensitive data scanning. Untrusted content from open-world tools is a primary vector for prompt injection data exfiltration. + +**Files**: `internal/runtime/activity_service.go`, `internal/server/mcp.go` +- In `handleCallToolVariant()` and code_execution handler, check if the called tool has `openWorldHint=true` +- If yes, add metadata tag `"content_trust": "untrusted"` to the activity record +- The sensitive data detector already scans responses; this adds context for audit + +### F4: Annotation-Based Filtering in retrieve_tools +Add optional filter parameters to `retrieve_tools` for agents to self-restrict tool discovery. + +**Files**: `internal/server/mcp.go`, `internal/server/mcp_routing.go` +- New optional parameters: + - `read_only_only` (bool): Only return tools with `readOnlyHint=true` + - `exclude_destructive` (bool): Exclude tools with `destructiveHint=true` or nil + - `exclude_open_world` (bool): Exclude tools with `openWorldHint=true` or nil +- Filter applied after BM25 search, before response building +- Update tool definition in `buildCallToolModeTools()` and `buildCodeExecModeTools()` + +### F5: Annotation Coverage Reporting +REST API endpoint and CLI output showing annotation adoption across connected servers. + +**Files**: `internal/httpapi/server.go`, `internal/server/mcp.go` +- New endpoint: `GET /api/v1/annotations/coverage` +- Response: + ```json + { + "total_tools": 45, + "annotated_tools": 12, + "coverage_percent": 26.7, + "servers": [ + { + "name": "github", + "total_tools": 20, + "annotated": 8, + "coverage_percent": 40.0 + } + ] + } + ``` +- Also add `annotation_coverage` field to retrieve_tools response when `include_stats=true` + +## Testing +- Unit tests for each feature +- E2E test verifying annotation filtering works via MCP protocol +- Verify quarantine hash change detection triggers on annotation changes