From 013da0706d25790869af56756a0db762ebc9830a Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Mon, 12 Jan 2026 21:31:42 +0200 Subject: [PATCH 1/6] feat(activity): expand activity log with new event types (Spec 024) Add comprehensive activity logging for system lifecycle, internal tool calls, and configuration changes: - Add 4 new activity types: system_start, system_stop, internal_tool_call, config_change - Implement multi-type filtering with OR logic (comma-separated types) - Log system startup/shutdown events with version and duration metadata - Log all internal MCP tool calls (retrieve_tools, call_tool_*, code_execution, upstream_servers, quarantine_security, list_registries, search_servers, read_cache) - Log configuration changes with action, affected entity, and changed values Web UI enhancements: - Multi-select dropdown for type filtering with checkboxes - Intent column with operation type badges and tooltips - Sortable columns (timestamp, type, server, status, duration) - Activity Log enabled in sidebar navigation CLI enhancements: - Support comma-separated --type values for multi-type filtering - Updated help text with new event types Co-Authored-By: Claude Opus 4.5 --- cmd/mcpproxy/activity_cmd.go | 34 +- cmd/mcpproxy/main.go | 8 + docs/cli/activity-commands.md | 57 ++- docs/features/activity-log.md | 78 +++- docs/web-ui/activity-log.md | 35 +- frontend/src/components/SidebarNav.vue | 3 +- frontend/src/views/Activity.vue | 276 ++++++++++-- internal/httpapi/activity.go | 6 +- internal/httpapi/activity_test.go | 28 +- internal/runtime/activity_service.go | 213 ++++++++++ internal/runtime/activity_service_test.go | 271 ++++++++++++ internal/runtime/event_bus.go | 71 ++++ internal/runtime/events.go | 10 + internal/server/mcp.go | 201 ++++++++- internal/server/mcp_code_execution.go | 12 + internal/server/server.go | 60 +++ internal/storage/activity_models.go | 37 +- internal/storage/activity_test.go | 41 +- oas/docs.go | 2 +- oas/swagger.yaml | 7 +- .../checklists/requirements.md | 45 ++ .../contracts/activity-api-changes.yaml | 312 ++++++++++++++ specs/024-expand-activity-log/data-model.md | 238 +++++++++++ specs/024-expand-activity-log/plan.md | 147 +++++++ specs/024-expand-activity-log/quickstart.md | 259 ++++++++++++ specs/024-expand-activity-log/research.md | 225 ++++++++++ specs/024-expand-activity-log/spec.md | 365 ++++++++++++++++ specs/024-expand-activity-log/tasks.md | 393 ++++++++++++++++++ 28 files changed, 3338 insertions(+), 96 deletions(-) create mode 100644 internal/runtime/activity_service_test.go create mode 100644 specs/024-expand-activity-log/checklists/requirements.md create mode 100644 specs/024-expand-activity-log/contracts/activity-api-changes.yaml create mode 100644 specs/024-expand-activity-log/data-model.md create mode 100644 specs/024-expand-activity-log/plan.md create mode 100644 specs/024-expand-activity-log/quickstart.md create mode 100644 specs/024-expand-activity-log/research.md create mode 100644 specs/024-expand-activity-log/spec.md create mode 100644 specs/024-expand-activity-log/tasks.md diff --git a/cmd/mcpproxy/activity_cmd.go b/cmd/mcpproxy/activity_cmd.go index 8fe4c85e..422b6a16 100644 --- a/cmd/mcpproxy/activity_cmd.go +++ b/cmd/mcpproxy/activity_cmd.go @@ -71,18 +71,26 @@ type ActivityFilter struct { // Validate validates the filter options func (f *ActivityFilter) Validate() error { - // Validate type + // Validate type(s) - supports comma-separated values (Spec 024) if f.Type != "" { - validTypes := []string{"tool_call", "policy_decision", "quarantine_change", "server_change"} - valid := false - for _, t := range validTypes { - if f.Type == t { - valid = true - break - } + validTypes := []string{ + "tool_call", "policy_decision", "quarantine_change", "server_change", + "system_start", "system_stop", "internal_tool_call", "config_change", // Spec 024: new types } - if !valid { - return fmt.Errorf("invalid type '%s': must be one of %v", f.Type, validTypes) + // Split by comma for multi-type support + types := strings.Split(f.Type, ",") + for _, t := range types { + t = strings.TrimSpace(t) + valid := false + for _, vt := range validTypes { + if t == vt { + valid = true + break + } + } + if !valid { + return fmt.Errorf("invalid type '%s': must be one of %v", t, validTypes) + } } } @@ -517,7 +525,7 @@ func init() { activityCmd.AddCommand(activityExportCmd) // List command flags - activityListCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type: tool_call, policy_decision, quarantine_change, server_change") + activityListCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type (comma-separated for multiple): tool_call, system_start, system_stop, internal_tool_call, config_change, policy_decision, quarantine_change, server_change") activityListCmd.Flags().StringVarP(&activityServer, "server", "s", "", "Filter by server name") activityListCmd.Flags().StringVar(&activityTool, "tool", "", "Filter by tool name") activityListCmd.Flags().StringVar(&activityStatus, "status", "", "Filter by status: success, error, blocked") @@ -531,7 +539,7 @@ func init() { activityListCmd.Flags().BoolVar(&activityNoIcons, "no-icons", false, "Disable emoji icons in output (use text instead)") // Watch command flags - activityWatchCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type: tool_call, policy_decision") + activityWatchCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type (comma-separated): tool_call, system_start, system_stop, internal_tool_call, config_change, policy_decision, quarantine_change, server_change") activityWatchCmd.Flags().StringVarP(&activityServer, "server", "s", "", "Filter by server name") // Show command flags @@ -547,7 +555,7 @@ func init() { activityExportCmd.Flags().StringVarP(&activityExportFormat, "format", "f", "json", "Export format: json, csv") activityExportCmd.Flags().BoolVar(&activityIncludeBodies, "include-bodies", false, "Include full request/response bodies") // Reuse list filter flags for export - activityExportCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type") + activityExportCmd.Flags().StringVarP(&activityType, "type", "t", "", "Filter by type (comma-separated): tool_call, system_start, system_stop, internal_tool_call, config_change, policy_decision, quarantine_change, server_change") activityExportCmd.Flags().StringVarP(&activityServer, "server", "s", "", "Filter by server name") activityExportCmd.Flags().StringVar(&activityTool, "tool", "", "Filter by tool name") activityExportCmd.Flags().StringVar(&activityStatus, "status", "", "Filter by status") diff --git a/cmd/mcpproxy/main.go b/cmd/mcpproxy/main.go index 4ef7f92a..fcbe6273 100644 --- a/cmd/mcpproxy/main.go +++ b/cmd/mcpproxy/main.go @@ -30,6 +30,7 @@ import ( "os" "os/signal" "strings" + "sync/atomic" "syscall" "time" @@ -554,6 +555,10 @@ func runServer(cmd *cobra.Command, _ []string) error { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + // Spec 024: Track received signal for activity logging + var receivedSignal atomic.Value + receivedSignal.Store("") + // Setup signal handling for graceful shutdown with force quit on second signal logger.Info("Signal handler goroutine starting - waiting for SIGINT or SIGTERM") _ = logger.Sync() @@ -561,6 +566,7 @@ func runServer(cmd *cobra.Command, _ []string) error { logger.Info("Signal handler goroutine is running, waiting for signal on channel") _ = logger.Sync() sig := <-sigChan + receivedSignal.Store(sig.String()) // Spec 024: Store signal for activity logging logger.Info("Received signal, shutting down", zap.String("signal", sig.String())) _ = logger.Sync() // Flush logs immediately so we can see shutdown messages logger.Info("Press Ctrl+C again within 10 seconds to force quit") @@ -592,6 +598,8 @@ func runServer(cmd *cobra.Command, _ []string) error { // Wait for context to be cancelled <-ctx.Done() logger.Info("Shutting down server") + // Spec 024: Set shutdown info for activity logging + srv.SetShutdownInfo("signal", receivedSignal.Load().(string)) // Use Shutdown() instead of StopServer() to ensure proper container cleanup // Shutdown() calls runtime.Close() which triggers ShutdownAll() for Docker cleanup if err := srv.Shutdown(); err != nil { diff --git a/docs/cli/activity-commands.md b/docs/cli/activity-commands.md index 1b971323..3bc2aa5b 100644 --- a/docs/cli/activity-commands.md +++ b/docs/cli/activity-commands.md @@ -54,7 +54,7 @@ mcpproxy activity list [flags] | Flag | Short | Default | Description | |------|-------|---------|-------------| -| `--type` | `-t` | | Filter by type: `tool_call`, `policy_decision`, `quarantine_change`, `server_change` | +| `--type` | `-t` | | Filter by type (comma-separated for multiple): `tool_call`, `system_start`, `system_stop`, `internal_tool_call`, `config_change`, `policy_decision`, `quarantine_change`, `server_change` | | `--server` | `-s` | | Filter by server name | | `--tool` | | | Filter by tool name | | `--status` | | | Filter by status: `success`, `error`, `blocked` | @@ -91,6 +91,18 @@ mcpproxy activity list --request-id a1b2c3d4-e5f6-7890-abcd-ef1234567890 # List activity as JSON mcpproxy activity list -o json +# List multiple event types (comma-separated) +mcpproxy activity list --type tool_call,config_change + +# List system lifecycle events +mcpproxy activity list --type system_start,system_stop + +# List internal tool calls (retrieve_tools, call_tool_*, upstream_servers, etc.) +mcpproxy activity list --type internal_tool_call + +# List configuration changes +mcpproxy activity list --type config_change + # List activity from today mcpproxy activity list --start-time "$(date -u +%Y-%m-%dT00:00:00Z)" @@ -163,7 +175,7 @@ mcpproxy activity watch [flags] | Flag | Short | Default | Description | |------|-------|---------|-------------| -| `--type` | `-t` | | Filter by type: `tool_call`, `policy_decision` | +| `--type` | `-t` | | Filter by type (comma-separated for multiple): `tool_call`, `system_start`, `system_stop`, `internal_tool_call`, `config_change`, `policy_decision`, `quarantine_change`, `server_change` | | `--server` | `-s` | | Filter by server name | ### Examples @@ -175,6 +187,9 @@ mcpproxy activity watch # Watch only tool calls from github mcpproxy activity watch --type tool_call --server github +# Watch system and config events +mcpproxy activity watch --type system_start,system_stop,config_change + # Watch with JSON output (NDJSON) mcpproxy activity watch -o json @@ -425,6 +440,12 @@ mcpproxy activity export --format csv | gzip > activity.csv.gz # Export errors only mcpproxy activity export --status error --output errors.jsonl + +# Export specific event types +mcpproxy activity export --type tool_call,internal_tool_call --output tool-calls.jsonl + +# Export config changes for audit +mcpproxy activity export --type config_change --output config-audit.jsonl ``` ### Output (JSON - JSON Lines) @@ -540,6 +561,38 @@ Hint: Use 'mcpproxy activity list' to find valid activity IDs --- +## Event Types Reference + +The activity log captures the following event types: + +| Type | Description | +|------|-------------| +| `tool_call` | Every tool call made through MCPProxy to upstream servers | +| `system_start` | MCPProxy server startup events | +| `system_stop` | MCPProxy server shutdown events | +| `internal_tool_call` | Internal proxy tool calls (`retrieve_tools`, `call_tool_*`, `code_execution`, `upstream_servers`, etc.) | +| `config_change` | Configuration changes (server added/removed/updated) | +| `policy_decision` | Tool calls blocked by policy rules | +| `quarantine_change` | Server quarantine/unquarantine events | +| `server_change` | Server enable/disable/restart events | + +### Multi-Type Filtering + +You can filter by multiple types using comma-separated values: + +```bash +# Filter by multiple types +mcpproxy activity list --type tool_call,internal_tool_call + +# System lifecycle events +mcpproxy activity list --type system_start,system_stop + +# All config-related events +mcpproxy activity list --type config_change,quarantine_change,server_change +``` + +--- + ## Tips - Use `--json` output for piping to `jq` for complex filtering diff --git a/docs/features/activity-log.md b/docs/features/activity-log.md index f914d65b..8c8ee1d2 100644 --- a/docs/features/activity-log.md +++ b/docs/features/activity-log.md @@ -18,10 +18,80 @@ The activity log captures: | Event Type | Description | |------------|-------------| | `tool_call` | Every tool call made through MCPProxy | +| `system_start` | MCPProxy server startup events | +| `system_stop` | MCPProxy server shutdown events | +| `internal_tool_call` | Internal proxy tool calls (retrieve_tools, call_tool_*, code_execution, etc.) | +| `config_change` | Configuration changes (server added/removed/updated) | | `policy_decision` | Tool calls blocked by policy rules | | `quarantine_change` | Server quarantine/unquarantine events | | `server_change` | Server enable/disable/restart events | +### System Lifecycle Events + +System lifecycle events track when MCPProxy starts and stops: + +```json +{ + "id": "01JFXYZ123DEF", + "type": "system_start", + "status": "success", + "timestamp": "2025-01-15T10:00:00Z", + "metadata": { + "version": "v0.5.0", + "listen_address": "127.0.0.1:8080", + "startup_duration_ms": 150, + "config_path": "/Users/user/.mcpproxy/mcp_config.json" + } +} +``` + +### Internal Tool Call Events + +Internal tool calls log when internal proxy tools are used: + +```json +{ + "id": "01JFXYZ123GHI", + "type": "internal_tool_call", + "status": "success", + "duration_ms": 45, + "timestamp": "2025-01-15T10:05:00Z", + "metadata": { + "internal_tool_name": "call_tool_read", + "target_server": "github-server", + "target_tool": "get_user", + "tool_variant": "call_tool_read", + "intent": { + "operation_type": "read", + "data_sensitivity": "public" + } + } +} +``` + +### Config Change Events + +Configuration changes are logged for audit trails: + +```json +{ + "id": "01JFXYZ123JKL", + "type": "config_change", + "server_name": "github-server", + "status": "success", + "timestamp": "2025-01-15T10:10:00Z", + "metadata": { + "action": "server_added", + "affected_entity": "github-server", + "source": "mcp", + "new_values": { + "name": "github-server", + "url": "https://api.github.com/mcp" + } + } +} +``` + ### Tool Call Records Each tool call record includes: @@ -151,7 +221,7 @@ GET /api/v1/activity | Parameter | Type | Description | |-----------|------|-------------| -| `type` | string | Filter by type: `tool_call`, `policy_decision`, `quarantine_change`, `server_change` | +| `type` | string | Filter by type (comma-separated for multiple): `tool_call`, `system_start`, `system_stop`, `internal_tool_call`, `config_change`, `policy_decision`, `quarantine_change`, `server_change` | | `server` | string | Filter by server name | | `tool` | string | Filter by tool name | | `session_id` | string | Filter by MCP session ID | @@ -167,6 +237,12 @@ GET /api/v1/activity # List recent tool calls curl -H "X-API-Key: $KEY" "http://127.0.0.1:8080/api/v1/activity?type=tool_call&limit=10" +# Filter by multiple types (comma-separated) +curl -H "X-API-Key: $KEY" "http://127.0.0.1:8080/api/v1/activity?type=tool_call,internal_tool_call,config_change" + +# List system lifecycle events +curl -H "X-API-Key: $KEY" "http://127.0.0.1:8080/api/v1/activity?type=system_start,system_stop" + # Filter by server curl -H "X-API-Key: $KEY" "http://127.0.0.1:8080/api/v1/activity?server=github-server" diff --git a/docs/web-ui/activity-log.md b/docs/web-ui/activity-log.md index da57a4b8..06c3a868 100644 --- a/docs/web-ui/activity-log.md +++ b/docs/web-ui/activity-log.md @@ -10,22 +10,29 @@ Access the Activity Log by navigating to `/ui/activity` in the MCPProxy web inte ### Activity Table -The main view displays activities in a table with the following columns: +The main view displays activities in a sortable table with the following columns: -| Column | Description | -|--------|-------------| -| Time | Timestamp with relative time display (e.g., "5m ago") | -| Type | Activity type with icon indicator | -| Server | Link to the server that generated the activity | -| Details | Tool name or action description | -| Status | Color-coded badge (green=success, red=error, orange=blocked) | -| Duration | Execution time in ms or seconds | +| Column | Sortable | Description | +|--------|----------|-------------| +| Time | Yes | Timestamp with relative time display (e.g., "5m ago"). Default sort: newest first | +| Type | Yes | Activity type with icon indicator | +| Server | Yes | Link to the server that generated the activity | +| Details | No | Tool name or action description | +| Intent | No | Operation type badge (read/write/destructive) with tooltip showing full intent details | +| Status | Yes | Color-coded badge (green=success, red=error, orange=blocked) | +| Duration | Yes | Execution time in ms or seconds | + +**Sorting**: Click any sortable column header to sort. Click again to toggle between ascending/descending. The current sort column and direction are indicated with an arrow. ### Activity Types | Type | Icon | Description | |------|------|-------------| -| Tool Call | 🔧 | MCP tool invocations | +| Tool Call | 🔧 | MCP tool invocations to upstream servers | +| System Start | 🚀 | MCPProxy server startup events | +| System Stop | 🛑 | MCPProxy server shutdown events | +| Internal Tool Call | ⚙️ | Internal proxy tool calls (`retrieve_tools`, `call_tool_*`, `code_execution`, `upstream_servers`, etc.) | +| Config Change | ⚡ | Configuration changes (server added/removed/updated) | | Policy Decision | 🛡️ | Security policy evaluations | | Quarantine Change | ⚠️ | Server quarantine status changes | | Server Change | 🔄 | Server enable/disable/restart events | @@ -40,12 +47,13 @@ Activities appear automatically via Server-Sent Events (SSE): ### Filtering Filter activities by: -- **Type**: Tool Call, Policy Decision, Quarantine Change, Server Change +- **Type**: Multi-select dropdown with checkboxes. Select one or more types to filter (uses OR logic between selected types): + - Tool Call, System Start, System Stop, Internal Tool Call, Config Change, Policy Decision, Quarantine Change, Server Change - **Server**: Dynamically populated from activity data - **Status**: Success, Error, Blocked - **Date Range**: From/To datetime pickers to filter by time period -Filters combine with AND logic. Active filters are displayed as badges below the filter controls. +Type filters combine with OR logic (show any selected type). Other filters combine with AND logic. Active filters are displayed as badges below the filter controls. ### Activity Details @@ -102,9 +110,10 @@ The Activity Log uses these REST API endpoints: | `GET /api/v1/activity/export` | Export activities (JSON/CSV) | Query parameters for filtering: -- `type`: Filter by activity type +- `type`: Filter by activity type (comma-separated for multiple, e.g., `?type=tool_call,config_change`) - `server`: Filter by server name - `status`: Filter by status +- `intent_type`: Filter by intent operation type (`read`, `write`, `destructive`) - `limit`: Maximum records to return - `offset`: Pagination offset diff --git a/frontend/src/components/SidebarNav.vue b/frontend/src/components/SidebarNav.vue index fcad6ccb..aa520140 100644 --- a/frontend/src/components/SidebarNav.vue +++ b/frontend/src/components/SidebarNav.vue @@ -77,8 +77,7 @@ const menuItems = [ { name: 'Secrets', path: '/secrets' }, { name: 'Search', path: '/search' }, { name: 'Tool Call History', path: '/tool-calls' }, - // TODO: Re-enable in next release - // { name: 'Activity Log', path: '/activity' }, + { name: 'Activity Log', path: '/activity' }, { name: 'Repositories', path: '/repositories' }, { name: 'Configuration', path: '/settings' }, ] diff --git a/frontend/src/views/Activity.vue b/frontend/src/views/Activity.vue index f1e82007..88757f85 100644 --- a/frontend/src/views/Activity.vue +++ b/frontend/src/views/Activity.vue @@ -54,18 +54,49 @@
- -
+ +
- +
@@ -146,7 +177,17 @@
Active filters: - Type: {{ formatType(filterType) }} + + {{ getTypeIcon(type) }} {{ formatType(type) }} + + + + Server: {{ filterServer }} Status: {{ filterStatus }} From: {{ new Date(filterStartDate).toLocaleString() }} @@ -186,12 +227,23 @@ - - - + + + - - + + + @@ -235,6 +287,20 @@ - + + + + +``` + +### Sortable Columns + +Add sort state and click handlers: +```vue + +``` + +--- + +## 6. CLI: Multi-Type Filter + +**File**: `cmd/mcpproxy/activity_cmd.go` + +Update type flag handling: +```go +typeFlag, _ := cmd.Flags().GetString("type") +if typeFlag != "" { + types := strings.Split(typeFlag, ",") + for _, t := range types { + if !isValidActivityType(t) { + return fmt.Errorf("invalid type: %s. Valid types: %s", t, validTypesString) + } + } + params.Set("type", typeFlag) +} +``` + +--- + +## 7. Documentation + +### Files to Update + +1. **`docs/features/activity-log.md`** + - Add new event types to table + - Add examples for system_start, system_stop, internal_tool_call, config_change + +2. **`docs/cli/activity-commands.md`** + - Add multi-type filter examples + - Document valid event types + +3. **`docs/web-ui/activity-log.md`** (create if needed) + - Document multi-select filter + - Document Intent column + - Document sortable columns + +--- + +## 8. Testing & Validation + +### Unit Tests + +```bash +go test ./internal/storage/... -v -run TestActivity +go test ./internal/runtime/... -v -run TestActivity +go test ./internal/httpapi/... -v -run TestActivity +``` + +### E2E Tests + +```bash +./scripts/test-api-e2e.sh +``` + +### Manual Testing + +1. **System Events**: Start/stop MCPProxy and check activity log +2. **Internal Tools**: Call retrieve_tools via MCP client +3. **Config Changes**: Add/remove server via CLI +4. **Multi-Type Filter**: Test API with `?type=tool_call,config_change` +5. **Web UI**: Verify multi-select, Intent column, sorting + +--- + +## Key Files Summary + +| Component | Files | +|-----------|-------| +| Activity Types | `internal/storage/activity_models.go` | +| Event Types | `internal/runtime/events.go` | +| Event Emission | `internal/server/server.go`, `internal/server/mcp.go` | +| Event Handling | `internal/runtime/activity_service.go` | +| REST API | `internal/httpapi/activity.go` | +| CLI | `cmd/mcpproxy/activity_cmd.go` | +| Web UI | `frontend/src/views/Activity.vue`, `frontend/src/components/SidebarNav.vue` | +| Docs | `docs/features/activity-log.md`, `docs/cli/activity-commands.md` | diff --git a/specs/024-expand-activity-log/research.md b/specs/024-expand-activity-log/research.md new file mode 100644 index 00000000..ed8ea2fb --- /dev/null +++ b/specs/024-expand-activity-log/research.md @@ -0,0 +1,225 @@ +# Research: Expand Activity Log + +**Date**: 2026-01-12 +**Feature**: 024-expand-activity-log + +## 1. New Event Types Implementation + +### 1.1 System Start/Stop Events + +**Research Task**: Where to emit system_start and system_stop events? + +**Findings**: +- Server lifecycle is in `cmd/mcpproxy/main.go`: + - `runServer()` function handles server startup (line 371) + - Server start: After `srv.StartServer(ctx)` succeeds (line 588) + - Server stop: Before `srv.Shutdown()` is called (line 597) +- Signal handling is in a goroutine (lines 560-584) +- Runtime is available via `srv.runtime` after `server.NewServerWithConfigPath()` + +**Decision**: Emit events from the Server struct in `internal/server/server.go`: +- `system_start` after successful HTTP listener bind and before returning from `StartServer()` +- `system_stop` at the beginning of `Shutdown()` method + +**Rationale**: This keeps event emission close to the actual lifecycle transitions and ensures runtime is available. + +**Alternatives Considered**: +1. Emit from `cmd/mcpproxy/main.go` - Rejected: Runtime not directly accessible +2. Emit from runtime initialization - Rejected: Too early, server not ready + +### 1.2 Internal Tool Call Events + +**Research Task**: How to capture internal tool calls (retrieve_tools, call_tool_*, etc.)? + +**Findings**: +- Internal tools are registered in `internal/server/mcp.go` `registerTools()` method (line 266) +- Tool handlers: `handleRetrieveTools`, `handleCallToolRead`, `handleCallToolWrite`, `handleCallToolDestructive` +- Existing activity emission pattern uses `emitActivityToolCallCompleted()` helper +- Current events are emitted for upstream tool calls only, not internal tools + +**Decision**: Add activity emission in each internal tool handler: +- Use `internal_tool_call` ActivityType (new constant) +- Include tool_name field to identify which internal tool was called +- Store query/arguments in the arguments field +- Include existing intent metadata for call_tool_* handlers + +**Rationale**: Follows existing pattern for tool call activity emission. + +**Alternatives Considered**: +1. Wrap all tool handlers with a decorator - Rejected: Overcomplicated, Go doesn't have decorators +2. Use middleware on MCP server - Rejected: mark3labs/mcp-go doesn't support middleware pattern + +### 1.3 Config Change Events + +**Research Task**: Where are config changes made and how to capture them? + +**Findings**: +- Server add/remove/update: `internal/management/server_manager.go` +- Config file changes: `internal/config/watcher.go` hot reload +- CLI operations: `cmd/mcpproxy/upstream_cmd.go` +- REST API: `internal/httpapi/servers.go` +- All modifications go through `storage.Manager` and emit `EventTypeServersChanged` + +**Decision**: Emit `config_change` activity when: +- Server is added (via ServerManager.AddServer) +- Server is removed (via ServerManager.RemoveServer) +- Server is updated (via ServerManager.UpdateServer) +- Use the existing `EventTypeServersChanged` subscription in ActivityService + +**Rationale**: ActivityService already subscribes to runtime events; extend it to handle config changes. + +**Alternatives Considered**: +1. Emit from storage layer - Rejected: Storage doesn't have runtime access +2. Emit from each CLI/API handler - Rejected: Duplication and easy to miss + +## 2. Activity Filter Multi-Type Support + +### 2.1 Backend Filter Logic + +**Research Task**: How does the current filter work? + +**Findings**: +- `ActivityFilter` struct in `internal/storage/activity_models.go` (line 66) +- Filter has single `Type string` field +- `Matches()` method does exact string match (line 105) + +**Decision**: Change `Type` field to `Types []string` and update `Matches()`: +- Accept comma-separated values in API/CLI +- Parse into slice internally +- Match if record type is in the slice (OR logic) +- Empty slice means no type filter (all types) + +**Rationale**: Minimal API change, backwards compatible (single type still works). + +### 2.2 API Changes + +**Research Task**: How does the API currently handle type filter? + +**Findings**: +- `GET /api/v1/activity` in `internal/httpapi/activity.go` +- Query parameter `type` is parsed as single string +- Passed to storage filter + +**Decision**: +- Accept comma-separated values: `?type=tool_call,config_change` +- Parse and split on comma +- Document in OpenAPI spec + +**Rationale**: Standard pattern for multi-value query parameters. + +## 3. Web UI Enhancements + +### 3.1 Multi-Select Dropdown Component + +**Research Task**: What UI library is used? + +**Findings**: +- Vue 3 + DaisyUI + Tailwind CSS +- Current type filter in `Activity.vue` uses a simple ` + +
+ + +
+
@@ -551,6 +565,7 @@ const autoRefresh = ref(true) // Filters const selectedTypes = ref([]) const filterServer = ref('') +const filterSession = ref('') const filterStatus = ref('') const filterStartDate = ref('') const filterEndDate = ref('') @@ -586,8 +601,42 @@ const availableServers = computed(() => { return Array.from(servers).sort() }) +// Available sessions with client name and session_id suffix (Spec 024) +interface SessionOption { + id: string + label: string + clientName?: string +} +const availableSessions = computed((): SessionOption[] => { + const sessionsMap = new Map() + activities.value.forEach(a => { + if (a.session_id && !sessionsMap.has(a.session_id)) { + // Try to get client name from metadata or any available source + const clientName = a.metadata?.client_name as string | undefined + sessionsMap.set(a.session_id, { clientName }) + } + }) + + return Array.from(sessionsMap.entries()) + .map(([sessionId, info]) => { + // Format: "ClientName ...12345" or "...12345" if no client name + const suffix = sessionId.slice(-5) + const label = info.clientName + ? `${info.clientName} ...${suffix}` + : `...${suffix}` + return { id: sessionId, label, clientName: info.clientName } + }) + .sort((a, b) => a.label.localeCompare(b.label)) +}) + +// Get session label by ID for display in Active Filters +const getSessionLabel = (sessionId: string): string => { + const session = availableSessions.value.find(s => s.id === sessionId) + return session?.label || `...${sessionId.slice(-5)}` +} + const hasActiveFilters = computed(() => { - return selectedTypes.value.length > 0 || filterServer.value || filterStatus.value || filterStartDate.value || filterEndDate.value + return selectedTypes.value.length > 0 || filterServer.value || filterSession.value || filterStatus.value || filterStartDate.value || filterEndDate.value }) const filteredActivities = computed(() => { @@ -600,6 +649,10 @@ const filteredActivities = computed(() => { if (filterServer.value) { result = result.filter(a => a.server_name === filterServer.value) } + // Session filter (Spec 024) + if (filterSession.value) { + result = result.filter(a => a.session_id === filterSession.value) + } if (filterStatus.value) { result = result.filter(a => a.status === filterStatus.value) } @@ -683,6 +736,7 @@ const loadActivities = async () => { const clearFilters = () => { selectedTypes.value = [] filterServer.value = '' + filterSession.value = '' filterStatus.value = '' filterStartDate.value = '' filterEndDate.value = '' diff --git a/internal/runtime/activity_service.go b/internal/runtime/activity_service.go index 569e7ed4..e3e4c1a3 100644 --- a/internal/runtime/activity_service.go +++ b/internal/runtime/activity_service.go @@ -2,6 +2,7 @@ package runtime import ( "context" + "encoding/json" "time" "go.uber.org/zap" @@ -188,6 +189,7 @@ func (s *ActivityService) handleToolCallCompleted(evt Event) { source := getStringPayload(evt.Payload, "source") status := getStringPayload(evt.Payload, "status") errorMsg := getStringPayload(evt.Payload, "error_message") + arguments := getMapPayload(evt.Payload, "arguments") response := getStringPayload(evt.Payload, "response") responseTruncated := getBoolPayload(evt.Payload, "response_truncated") durationMs := getInt64Payload(evt.Payload, "duration_ms") @@ -218,6 +220,7 @@ func (s *ActivityService) handleToolCallCompleted(evt Event) { Source: activitySource, ServerName: serverName, ToolName: toolName, + Arguments: arguments, Response: response, ResponseTruncated: responseTruncated, Status: status, @@ -383,6 +386,21 @@ func (s *ActivityService) handleInternalToolCall(evt Event) { errorMsg := getStringPayload(evt.Payload, "error_message") durationMs := getInt64Payload(evt.Payload, "duration_ms") intent := getMapPayload(evt.Payload, "intent") + arguments := getMapPayload(evt.Payload, "arguments") + + // Extract response - can be various types, convert to string + var responseStr string + if resp := evt.Payload["response"]; resp != nil { + switch r := resp.(type) { + case string: + responseStr = r + default: + // Convert to JSON for other types + if jsonBytes, err := json.Marshal(r); err == nil { + responseStr = string(jsonBytes) + } + } + } metadata := map[string]interface{}{ "internal_tool_name": internalToolName, @@ -405,6 +423,8 @@ func (s *ActivityService) handleInternalToolCall(evt Event) { Source: storage.ActivitySourceMCP, ToolName: internalToolName, ServerName: targetServer, + Arguments: arguments, + Response: responseStr, Status: status, ErrorMessage: errorMsg, DurationMs: durationMs, diff --git a/internal/runtime/activity_service_test.go b/internal/runtime/activity_service_test.go index e8d26827..dc491a10 100644 --- a/internal/runtime/activity_service_test.go +++ b/internal/runtime/activity_service_test.go @@ -182,6 +182,13 @@ func TestEmitActivityInternalToolCall(t *testing.T) { "operation_type": "read", "data_sensitivity": "public", } + testArgs := map[string]interface{}{ + "username": "octocat", + } + testResponse := map[string]interface{}{ + "login": "octocat", + "id": 1, + } rt.EmitActivityInternalToolCall( "call_tool_read", "github", @@ -192,6 +199,8 @@ func TestEmitActivityInternalToolCall(t *testing.T) { "success", "", 250, + testArgs, + testResponse, intent, ) @@ -209,6 +218,13 @@ func TestEmitActivityInternalToolCall(t *testing.T) { assert.Equal(t, "", evt.Payload["error_message"]) assert.Equal(t, int64(250), evt.Payload["duration_ms"]) assert.NotNil(t, evt.Payload["intent"]) + // Verify arguments and response are captured + assert.NotNil(t, evt.Payload["arguments"]) + args := evt.Payload["arguments"].(map[string]interface{}) + assert.Equal(t, "octocat", args["username"]) + assert.NotNil(t, evt.Payload["response"]) + resp := evt.Payload["response"].(map[string]interface{}) + assert.Equal(t, "octocat", resp["login"]) case <-time.After(2 * time.Second): t.Fatal("Did not receive activity.internal_tool_call.completed event within timeout") } diff --git a/internal/runtime/event_bus.go b/internal/runtime/event_bus.go index 33344307..7db30913 100644 --- a/internal/runtime/event_bus.go +++ b/internal/runtime/event_bus.go @@ -102,9 +102,10 @@ func (r *Runtime) EmitActivityToolCallStarted(serverName, toolName, sessionID, r // EmitActivityToolCallCompleted emits an event when a tool execution finishes. // This is used to track activity for observability and debugging. // source indicates how the call was triggered: "mcp", "cli", or "api" +// 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, 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{}) { payload := map[string]any{ "server_name": serverName, "tool_name": toolName, @@ -117,6 +118,10 @@ func (r *Runtime) EmitActivityToolCallCompleted(serverName, toolName, sessionID, "response": response, "response_truncated": responseTruncated, } + // Add arguments if provided + if arguments != nil { + payload["arguments"] = arguments + } // Add intent metadata if provided (Spec 018) if toolVariant != "" { payload["tool_variant"] = toolVariant @@ -174,8 +179,9 @@ func (r *Runtime) EmitActivitySystemStop(reason, signal string, uptimeSeconds in // EmitActivityInternalToolCall emits an event when an internal tool is called (Spec 024). // internalToolName is the name of the internal tool (retrieve_tools, call_tool_read, etc.) // targetServer and targetTool are used for call_tool_* handlers +// arguments contains the input parameters, response contains the output // intent is the intent declaration metadata -func (r *Runtime) EmitActivityInternalToolCall(internalToolName, targetServer, targetTool, toolVariant, sessionID, requestID, status, errorMsg string, durationMs int64, intent map[string]interface{}) { +func (r *Runtime) EmitActivityInternalToolCall(internalToolName, targetServer, targetTool, toolVariant, sessionID, requestID, status, errorMsg string, durationMs int64, arguments map[string]interface{}, response interface{}, intent map[string]interface{}) { payload := map[string]any{ "internal_tool_name": internalToolName, "session_id": sessionID, @@ -193,6 +199,12 @@ func (r *Runtime) EmitActivityInternalToolCall(internalToolName, targetServer, t if toolVariant != "" { payload["tool_variant"] = toolVariant } + if arguments != nil { + payload["arguments"] = arguments + } + if response != nil { + payload["response"] = response + } if intent != nil { payload["intent"] = intent } diff --git a/internal/runtime/lifecycle.go b/internal/runtime/lifecycle.go index 590ddd12..1aea0bc0 100644 --- a/internal/runtime/lifecycle.go +++ b/internal/runtime/lifecycle.go @@ -895,6 +895,13 @@ func (r *Runtime) EnableServer(serverName string, enabled bool) error { "enabled": enabled, }) + // Emit config change activity for audit trail (Spec 024) + action := "server_disabled" + if enabled { + action = "server_enabled" + } + r.EmitActivityConfigChange(action, serverName, "api", []string{"enabled"}, map[string]interface{}{"enabled": !enabled}, map[string]interface{}{"enabled": enabled}) + r.HandleUpstreamServerChange(r.AppContext()) return nil diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 1c3eb01e..78ab849e 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -248,11 +248,12 @@ func (p *MCPProxyServer) emitActivityToolCallStarted(serverName, toolName, sessi // emitActivityToolCallCompleted safely emits a tool call completion event if runtime is available // source indicates how the call was triggered: "mcp", "cli", or "api" +// 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, 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{}) { if p.mainServer != nil && p.mainServer.runtime != nil { - p.mainServer.runtime.EmitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, source, status, errorMsg, durationMs, response, responseTruncated, toolVariant, intent) + p.mainServer.runtime.EmitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, source, status, errorMsg, durationMs, arguments, response, responseTruncated, toolVariant, intent) } } @@ -265,10 +266,11 @@ func (p *MCPProxyServer) emitActivityPolicyDecision(serverName, toolName, sessio // emitActivityInternalToolCall safely emits an internal tool call completion event (Spec 024) // internalToolName is the name of the internal tool (retrieve_tools, call_tool_read, etc.) // targetServer and targetTool are used for call_tool_* handlers +// arguments contains the input parameters, response contains the output // intent is the intent declaration metadata -func (p *MCPProxyServer) emitActivityInternalToolCall(internalToolName, targetServer, targetTool, toolVariant, sessionID, requestID, status, errorMsg string, durationMs int64, intent map[string]interface{}) { +func (p *MCPProxyServer) emitActivityInternalToolCall(internalToolName, targetServer, targetTool, toolVariant, sessionID, requestID, status, errorMsg string, durationMs int64, arguments map[string]interface{}, response interface{}, intent map[string]interface{}) { if p.mainServer != nil && p.mainServer.runtime != nil { - p.mainServer.runtime.EmitActivityInternalToolCall(internalToolName, targetServer, targetTool, toolVariant, sessionID, requestID, status, errorMsg, durationMs, intent) + p.mainServer.runtime.EmitActivityInternalToolCall(internalToolName, targetServer, targetTool, toolVariant, sessionID, requestID, status, errorMsg, durationMs, arguments, response, intent) } } @@ -627,7 +629,7 @@ func (p *MCPProxyServer) handleSearchServers(ctx context.Context, request mcp.Ca registry, err := request.RequireString("registry") if err != nil { - p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Missing required parameter 'registry': %v", err)), nil } @@ -650,7 +652,7 @@ func (p *MCPProxyServer) handleSearchServers(ctx context.Context, request mcp.Ca zap.String("search", search), zap.String("tag", tag), zap.Error(err)) - p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Search failed: %v", err)), nil } @@ -674,12 +676,12 @@ func (p *MCPProxyServer) handleSearchServers(ctx context.Context, request mcp.Ca jsonResult, err := json.Marshal(response) if err != nil { - p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Failed to serialize results: %v", err)), nil } // Spec 024: Emit success event - p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultText(string(jsonResult)), nil } @@ -715,12 +717,12 @@ func (p *MCPProxyServer) handleListRegistries(ctx context.Context, _ mcp.CallToo "message": "Available MCP registries. Use 'search_servers' tool with a registry ID to find servers.", }) if err != nil { - p.emitActivityInternalToolCall("list_registries", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("list_registries", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Failed to serialize registries: %v", err)), nil } // Spec 024: Emit success event - p.emitActivityInternalToolCall("list_registries", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("list_registries", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultText(string(jsonResult)), nil } @@ -739,7 +741,7 @@ func (p *MCPProxyServer) handleRetrieveTools(ctx context.Context, request mcp.Ca query, err := request.RequireString("query") if err != nil { // Emit internal tool call event for error case - p.emitActivityInternalToolCall("retrieve_tools", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("retrieve_tools", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Missing required parameter 'query': %v", err)), nil } @@ -758,7 +760,7 @@ func (p *MCPProxyServer) handleRetrieveTools(ctx context.Context, request mcp.Ca results, err := p.index.Search(query, limit) if err != nil { p.logger.Error("Search failed", zap.String("query", query), zap.Error(err)) - p.emitActivityInternalToolCall("retrieve_tools", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("retrieve_tools", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Search failed: %v", err)), nil } @@ -859,12 +861,12 @@ func (p *MCPProxyServer) handleRetrieveTools(ctx context.Context, request mcp.Ca jsonResult, err := json.Marshal(response) if err != nil { - p.emitActivityInternalToolCall("retrieve_tools", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("retrieve_tools", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Failed to serialize results: %v", err)), nil } // Emit success event (Spec 024) - p.emitActivityInternalToolCall("retrieve_tools", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("retrieve_tools", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultText(string(jsonResult)), nil } @@ -1006,6 +1008,32 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp. return errResult, nil } + // Extract session information from context early (needed for activity events, including early failures) + var sessionID, clientName, clientVersion string + if sess := mcpserver.ClientSessionFromContext(ctx); sess != nil { + sessionID = sess.SessionID() + if sessInfo := p.sessionStore.GetSession(sessionID); sessInfo != nil { + clientName = sessInfo.ClientName + clientVersion = sessInfo.ClientVersion + } + } + + // Determine activity source from context (CLI/API calls set this, MCP calls have session) + activitySource := "mcp" + if reqSource := reqcontext.GetRequestSource(ctx); reqSource != reqcontext.SourceUnknown { + switch reqSource { + case reqcontext.SourceCLI: + activitySource = "cli" + case reqcontext.SourceRESTAPI: + activitySource = "api" + default: + activitySource = "mcp" + } + } + + // Generate requestID for activity tracking + requestID := fmt.Sprintf("%d-%s-%s", time.Now().UnixNano(), serverName, actualToolName) + // Check if server is quarantined before calling tool serverConfig, err := p.storage.GetUpstreamServer(serverName) if err == nil && serverConfig.Quarantined { @@ -1023,43 +1051,35 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp. if client, exists := p.upstreamManager.GetClient(serverName); exists { if !client.IsConnected() { state := client.GetState() + errMsg := "" if client.IsConnecting() { - return mcp.NewToolResultError(fmt.Sprintf("Server '%s' is currently connecting - please wait for connection to complete (state: %s)", serverName, state.String())), nil + errMsg = fmt.Sprintf("Server '%s' is currently connecting - please wait for connection to complete (state: %s)", serverName, state.String()) + } else { + errMsg = fmt.Sprintf("Server '%s' is not connected (state: %s) - use 'upstream_servers' tool to check server configuration", serverName, state.String()) + } + // Log the early failure to activity (Spec 024) + var intentMap map[string]interface{} + if intent != nil { + intentMap = intent.ToMap() } - return mcp.NewToolResultError(fmt.Sprintf("Server '%s' is not connected (state: %s) - use 'upstream_servers' tool to check server configuration", serverName, state.String())), nil + p.emitActivityToolCallStarted(serverName, actualToolName, sessionID, requestID, activitySource, args) + p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, args, errMsg, false, toolVariant, intentMap) + return mcp.NewToolResultError(errMsg), nil } } else { p.logger.Error("handleCallToolVariant: no client found for server", zap.String("server_name", serverName)) - return mcp.NewToolResultError(fmt.Sprintf("No client found for server: %s", serverName)), nil - } - - // Extract session information from context (needed for activity events) - var sessionID, clientName, clientVersion string - if sess := mcpserver.ClientSessionFromContext(ctx); sess != nil { - sessionID = sess.SessionID() - if sessInfo := p.sessionStore.GetSession(sessionID); sessInfo != nil { - clientName = sessInfo.ClientName - clientVersion = sessInfo.ClientVersion - } - } - - // Determine activity source from context (CLI/API calls set this, MCP calls have session) - activitySource := "mcp" - if reqSource := reqcontext.GetRequestSource(ctx); reqSource != reqcontext.SourceUnknown { - switch reqSource { - case reqcontext.SourceCLI: - activitySource = "cli" - case reqcontext.SourceRESTAPI: - activitySource = "api" - default: - activitySource = "mcp" + errMsg := fmt.Sprintf("No client found for server: %s", serverName) + // Log the early failure to activity (Spec 024) + var intentMap map[string]interface{} + if intent != nil { + intentMap = intent.ToMap() } + p.emitActivityToolCallStarted(serverName, actualToolName, sessionID, requestID, activitySource, args) + p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, args, errMsg, false, toolVariant, intentMap) + return mcp.NewToolResultError(errMsg), nil } - // Generate requestID for activity tracking - requestID := fmt.Sprintf("%d-%s-%s", time.Now().UnixNano(), serverName, actualToolName) - // Emit activity started event with determined source p.emitActivityToolCallStarted(serverName, actualToolName, sessionID, requestID, activitySource, args) @@ -1147,11 +1167,11 @@ 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(), "", false, toolVariant, intentMap) + p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", err.Error(), duration.Milliseconds(), args, "", false, toolVariant, intentMap) // Spec 024: Emit internal tool call event for error internalToolName := "call_tool_" + intent.OperationType // e.g., "call_tool_read" - p.emitActivityInternalToolCall(internalToolName, serverName, actualToolName, toolVariant, sessionID, requestID, "error", err.Error(), time.Since(internalStartTime).Milliseconds(), intentMap) + p.emitActivityInternalToolCall(internalToolName, serverName, actualToolName, toolVariant, sessionID, requestID, "error", err.Error(), time.Since(internalStartTime).Milliseconds(), args, nil, intentMap) return p.createDetailedErrorResponse(err, serverName, actualToolName), nil } @@ -1251,11 +1271,11 @@ 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(), response, responseTruncated, toolVariant, intentMap) + p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "success", "", duration.Milliseconds(), args, response, responseTruncated, toolVariant, intentMap) // Spec 024: Emit internal tool call event for success internalToolName := "call_tool_" + intent.OperationType // e.g., "call_tool_read" - p.emitActivityInternalToolCall(internalToolName, serverName, actualToolName, toolVariant, sessionID, requestID, "success", "", time.Since(internalStartTime).Milliseconds(), intentMap) + p.emitActivityInternalToolCall(internalToolName, serverName, actualToolName, toolVariant, sessionID, requestID, "success", "", time.Since(internalStartTime).Milliseconds(), args, result, intentMap) return mcp.NewToolResultText(response), nil } @@ -1359,20 +1379,40 @@ func (p *MCPProxyServer) handleCallTool(ctx context.Context, request mcp.CallToo zap.String("actual_tool_name", actualToolName), zap.Any("args", args)) + // Extract session information from context early (needed for activity events, including early failures) + var sessionID, clientName, clientVersion string + if sess := mcpserver.ClientSessionFromContext(ctx); sess != nil { + sessionID = sess.SessionID() + if sessInfo := p.sessionStore.GetSession(sessionID); sessInfo != nil { + clientName = sessInfo.ClientName + clientVersion = sessInfo.ClientVersion + } + } + + // Determine activity source from context (CLI/API calls set this, MCP calls have session) + activitySource := "mcp" + if reqSource := reqcontext.GetRequestSource(ctx); reqSource != reqcontext.SourceUnknown { + switch reqSource { + case reqcontext.SourceCLI: + activitySource = "cli" + case reqcontext.SourceRESTAPI: + activitySource = "api" + default: + activitySource = "mcp" + } + } + + // Generate requestID for activity tracking + requestID := fmt.Sprintf("%d-%s-%s", time.Now().UnixNano(), serverName, actualToolName) + // Check if server is quarantined before calling tool serverConfig, err := p.storage.GetUpstreamServer(serverName) if err == nil && serverConfig.Quarantined { p.logger.Debug("handleCallTool: server is quarantined", zap.String("server_name", serverName)) - // Extract session ID for activity logging - var quarantineSessionID string - if sess := mcpserver.ClientSessionFromContext(ctx); sess != nil { - quarantineSessionID = sess.SessionID() - } - // Emit policy decision event for quarantine block - p.emitActivityPolicyDecision(serverName, actualToolName, quarantineSessionID, "blocked", "Server is quarantined for security review") + p.emitActivityPolicyDecision(serverName, actualToolName, sessionID, "blocked", "Server is quarantined for security review") // Server is in quarantine - return security warning with tool analysis return p.handleQuarantinedToolCall(ctx, serverName, actualToolName, args), nil @@ -1390,47 +1430,31 @@ func (p *MCPProxyServer) handleCallTool(ctx context.Context, request mcp.CallToo if !client.IsConnected() { state := client.GetState() + errMsg := "" if client.IsConnecting() { - return mcp.NewToolResultError(fmt.Sprintf("Server '%s' is currently connecting - please wait for connection to complete (state: %s)", serverName, state.String())), nil + errMsg = fmt.Sprintf("Server '%s' is currently connecting - please wait for connection to complete (state: %s)", serverName, state.String()) + } else { + errMsg = fmt.Sprintf("Server '%s' is not connected (state: %s) - use 'upstream_servers' tool to check server configuration", serverName, state.String()) } - return mcp.NewToolResultError(fmt.Sprintf("Server '%s' is not connected (state: %s) - use 'upstream_servers' tool to check server configuration", serverName, state.String())), nil + // Log the early failure to activity (Spec 024) + p.emitActivityToolCallStarted(serverName, actualToolName, sessionID, requestID, activitySource, args) + p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, args, errMsg, false, "", nil) + return mcp.NewToolResultError(errMsg), nil } } else { p.logger.Error("handleCallTool: no client found for server", zap.String("server_name", serverName)) - return mcp.NewToolResultError(fmt.Sprintf("No client found for server: %s", serverName)), nil + 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, args) + p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, args, errMsg, false, "", nil) + return mcp.NewToolResultError(errMsg), nil } p.logger.Debug("handleCallTool: calling upstream manager", zap.String("tool_name", toolName), zap.String("server_name", serverName)) - // Extract session information from context (needed for activity events) - var sessionID, clientName, clientVersion string - if sess := mcpserver.ClientSessionFromContext(ctx); sess != nil { - sessionID = sess.SessionID() - if sessInfo := p.sessionStore.GetSession(sessionID); sessInfo != nil { - clientName = sessInfo.ClientName - clientVersion = sessInfo.ClientVersion - } - } - - // Determine activity source from context (CLI/API calls set this, MCP calls have session) - activitySource := "mcp" - if reqSource := reqcontext.GetRequestSource(ctx); reqSource != reqcontext.SourceUnknown { - switch reqSource { - case reqcontext.SourceCLI: - activitySource = "cli" - case reqcontext.SourceRESTAPI: - activitySource = "api" - default: - activitySource = "mcp" - } - } - - // Generate requestID for activity tracking - requestID := fmt.Sprintf("%d-%s-%s", time.Now().UnixNano(), serverName, actualToolName) - // Emit activity started event with determined source p.emitActivityToolCallStarted(serverName, actualToolName, sessionID, requestID, activitySource, args) @@ -1535,7 +1559,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(), "", false, "", nil) + p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", err.Error(), duration.Milliseconds(), args, "", false, "", nil) return p.createDetailedErrorResponse(err, serverName, actualToolName), nil } @@ -1631,7 +1655,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(), response, responseTruncated, "", nil) + p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "success", "", duration.Milliseconds(), args, response, responseTruncated, "", nil) return mcp.NewToolResultText(response), nil } @@ -1702,20 +1726,20 @@ func (p *MCPProxyServer) handleUpstreamServers(ctx context.Context, request mcp. operation, err := request.RequireString("operation") if err != nil { - p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Missing required parameter 'operation': %v", err)), nil } // Security checks if p.config.ReadOnlyMode { if operation != operationList { - p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Operation not allowed in read-only mode", time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Operation not allowed in read-only mode", time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError("Operation not allowed in read-only mode"), nil } } if p.config.DisableManagement { - p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Server management is disabled for security", time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Server management is disabled for security", time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError("Server management is disabled for security"), nil } @@ -1723,12 +1747,12 @@ func (p *MCPProxyServer) handleUpstreamServers(ctx context.Context, request mcp. switch operation { case operationAdd: if !p.config.AllowServerAdd { - p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Adding servers is not allowed", time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Adding servers is not allowed", time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError("Adding servers is not allowed"), nil } case operationRemove: if !p.config.AllowServerRemove { - p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Removing servers is not allowed", time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Removing servers is not allowed", time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError("Removing servers is not allowed"), nil } } @@ -1757,13 +1781,13 @@ func (p *MCPProxyServer) handleUpstreamServers(ctx context.Context, request mcp. case "restart": result, opErr = p.handleRestartUpstream(ctx, request) default: - p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", fmt.Sprintf("Unknown operation: %s", operation), time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", fmt.Sprintf("Unknown operation: %s", operation), time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Unknown operation: %s", operation)), nil } // Spec 024: Emit activity event based on result if opErr != nil { - p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", opErr.Error(), time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", opErr.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) } else if result != nil && result.IsError { // Extract error message from result if available errMsg := "operation failed" @@ -1772,9 +1796,9 @@ func (p *MCPProxyServer) handleUpstreamServers(ctx context.Context, request mcp. errMsg = textContent.Text } } - p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", errMsg, time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", errMsg, time.Since(startTime).Milliseconds(), nil, nil, nil) } else { - p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil, nil, nil) } return result, opErr @@ -1793,18 +1817,18 @@ func (p *MCPProxyServer) handleQuarantineSecurity(ctx context.Context, request m operation, err := request.RequireString("operation") if err != nil { - p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Missing required parameter 'operation': %v", err)), nil } // Security checks if p.config.ReadOnlyMode { - p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", "Quarantine operations not allowed in read-only mode", time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", "Quarantine operations not allowed in read-only mode", time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError("Quarantine operations not allowed in read-only mode"), nil } if p.config.DisableManagement { - p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", "Server management is disabled for security", time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", "Server management is disabled for security", time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError("Server management is disabled for security"), nil } @@ -1820,13 +1844,13 @@ func (p *MCPProxyServer) handleQuarantineSecurity(ctx context.Context, request m case "quarantine": result, opErr = p.handleQuarantineUpstream(ctx, request) default: - p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", fmt.Sprintf("Unknown quarantine operation: %s", operation), time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", fmt.Sprintf("Unknown quarantine operation: %s", operation), time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Unknown quarantine operation: %s", operation)), nil } // Spec 024: Emit activity event based on result if opErr != nil { - p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", opErr.Error(), time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", opErr.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) } else if result != nil && result.IsError { // Extract error message from result if available errMsg := "operation failed" @@ -1835,9 +1859,9 @@ func (p *MCPProxyServer) handleQuarantineSecurity(ctx context.Context, request m errMsg = textContent.Text } } - p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", errMsg, time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", errMsg, time.Since(startTime).Milliseconds(), nil, nil, nil) } else { - p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil, nil, nil) } return result, opErr @@ -3263,7 +3287,7 @@ func (p *MCPProxyServer) handleReadCache(ctx context.Context, request mcp.CallTo key, err := request.RequireString("key") if err != nil { - p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Missing required parameter 'key': %v", err)), nil } @@ -3273,30 +3297,30 @@ func (p *MCPProxyServer) handleReadCache(ctx context.Context, request mcp.CallTo // Validate parameters if offset < 0 { - p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", "Offset must be non-negative", time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", "Offset must be non-negative", time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError("Offset must be non-negative"), nil } if limit <= 0 || limit > 1000 { - p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", "Limit must be between 1 and 1000", time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", "Limit must be between 1 and 1000", time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError("Limit must be between 1 and 1000"), nil } // Retrieve cached data response, err := p.cacheManager.GetRecords(key, offset, limit) if err != nil { - p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Failed to retrieve cached data: %v", err)), nil } // Serialize response jsonResult, err := json.Marshal(response) if err != nil { - p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Failed to serialize response: %v", err)), nil } // Spec 024: Emit success event - p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil) + p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultText(string(jsonResult)), nil } diff --git a/internal/server/mcp_code_execution.go b/internal/server/mcp_code_execution.go index f1aa801c..b9bd11dc 100644 --- a/internal/server/mcp_code_execution.go +++ b/internal/server/mcp_code_execution.go @@ -306,7 +306,11 @@ func (p *MCPProxyServer) handleCodeExecution(ctx context.Context, request mcp.Ca errorMsg = result.Error.Message } } - p.emitActivityInternalToolCall("code_execution", "", "", "", sessionID, parentCallID, status, errorMsg, executionDuration.Milliseconds(), nil) + codeExecArgs := map[string]interface{}{ + "code": code, + "input": options.Input, + } + p.emitActivityInternalToolCall("code_execution", "", "", "", sessionID, parentCallID, status, errorMsg, executionDuration.Milliseconds(), codeExecArgs, result, nil) return &mcp.CallToolResult{ Content: []mcp.Content{ From d74d3d540f7ee544d5426f45e04dcf49991b1496 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 13 Jan 2026 10:01:38 +0200 Subject: [PATCH 3/6] fix(activity): fix auto-refresh and CLI watch for new event types (Spec 024) - Fix SSE event listeners for system lifecycle events - Changed activity.system_start to activity.system.start (matches backend) - Changed activity.system_stop to activity.system.stop (matches backend) - Expand Activity.vue handlers to detect all event type fields - internal_tool_call: internal_tool_name, target_server, target_tool - config_change: action, affected_entity - system_start/stop: version, listen_address, reason - Fix CLI activity watch to show all event types (not just .completed) - Added dedicated formatters for each event type - Shows config_change, system_start, system_stop, policy_decision - Improved filtering for server name across different event types Co-Authored-By: Claude Opus 4.5 --- cmd/mcpproxy/activity_cmd.go | 167 ++++++++++++++++++++++++++++---- frontend/src/stores/system.ts | 6 +- frontend/src/views/Activity.vue | 18 +++- 3 files changed, 167 insertions(+), 24 deletions(-) diff --git a/cmd/mcpproxy/activity_cmd.go b/cmd/mcpproxy/activity_cmd.go index 422b6a16..7c36276d 100644 --- a/cmd/mcpproxy/activity_cmd.go +++ b/cmd/mcpproxy/activity_cmd.go @@ -871,8 +871,9 @@ func watchActivityStream(ctx context.Context, sseURL string, outputFormat string eventData = strings.TrimPrefix(line, "data: ") case line == "": // Empty line = event complete - // Only display completed events (started events have no status/duration) - if strings.HasPrefix(eventType, "activity.") && strings.HasSuffix(eventType, ".completed") { + // Display all activity events except .started (which have no meaningful status/duration) + // Includes: .completed, policy_decision, system_start, system_stop, config_change + if strings.HasPrefix(eventType, "activity.") && !strings.HasSuffix(eventType, ".started") { displayActivityEvent(eventType, eventData, outputFormat) } eventType, eventData = "", "" @@ -903,22 +904,61 @@ func displayActivityEvent(eventType, eventData, outputFormat string) { event = wrapper } + // Determine event category from eventType (e.g., "activity.tool_call.completed" -> "tool_call") + parts := strings.Split(eventType, ".") + eventCategory := "" + if len(parts) >= 2 { + eventCategory = parts[1] + } + // Apply client-side filters if activityServer != "" { - if server := getStringField(event, "server_name"); server != activityServer { + // For tool_call events, check server_name + // For internal_tool_call events, check target_server + server := getStringField(event, "server_name") + if server == "" { + server = getStringField(event, "target_server") + } + if server == "" { + server = getStringField(event, "affected_entity") // for config_change + } + if server != activityServer { return } } if activityType != "" { - // Event type is like "activity.tool_call.completed", extract the middle part - parts := strings.Split(eventType, ".") - if len(parts) >= 2 && parts[1] != activityType { + if eventCategory != activityType { return } } - // Format for table output: [HH:MM:SS] [SRC] server:tool status duration + // Format output based on event type timestamp := time.Now().Format("15:04:05") + + var line string + switch eventCategory { + case "tool_call": + line = formatToolCallEvent(event, timestamp) + case "internal_tool_call": + line = formatInternalToolCallEvent(event, timestamp) + case "policy_decision": + line = formatPolicyDecisionEvent(event, timestamp) + case "system_start": + line = formatSystemStartEvent(event, timestamp) + case "system_stop": + line = formatSystemStopEvent(event, timestamp) + case "config_change": + line = formatConfigChangeEvent(event, timestamp) + default: + // Fallback for unknown event types + line = fmt.Sprintf("[%s] [?] %s", timestamp, eventType) + } + + fmt.Println(line) +} + +// formatToolCallEvent formats a tool_call event for display +func formatToolCallEvent(event map[string]interface{}, timestamp string) string { source := getStringField(event, "source") server := getStringField(event, "server_name") tool := getStringField(event, "tool_name") @@ -926,19 +966,8 @@ func displayActivityEvent(eventType, eventData, outputFormat string) { durationMs := getIntField(event, "duration_ms") errMsg := getStringField(event, "error_message") - // Source indicator sourceIcon := formatSourceIndicator(source) - - // Status indicator - statusIcon := "?" - switch status { - case "success": - statusIcon = "\u2713" // checkmark - case "error": - statusIcon = "\u2717" // X - case "blocked": - statusIcon = "\u2298" // circle with slash - } + statusIcon := formatStatusIcon(status) line := fmt.Sprintf("[%s] [%s] %s:%s %s %s", timestamp, sourceIcon, server, tool, statusIcon, formatActivityDuration(int64(durationMs))) if errMsg != "" { @@ -947,8 +976,106 @@ func displayActivityEvent(eventType, eventData, outputFormat string) { if status == "blocked" { line += " BLOCKED" } + return line +} - fmt.Println(line) +// formatInternalToolCallEvent formats an internal_tool_call event for display +func formatInternalToolCallEvent(event map[string]interface{}, timestamp string) string { + internalTool := getStringField(event, "internal_tool_name") + targetServer := getStringField(event, "target_server") + targetTool := getStringField(event, "target_tool") + status := getStringField(event, "status") + durationMs := getIntField(event, "duration_ms") + errMsg := getStringField(event, "error_message") + + statusIcon := formatStatusIcon(status) + + // Format: [HH:MM:SS] [INT] internal_tool -> target_server:target_tool status duration + target := "" + if targetServer != "" && targetTool != "" { + target = fmt.Sprintf(" -> %s:%s", targetServer, targetTool) + } else if targetServer != "" { + target = fmt.Sprintf(" -> %s", targetServer) + } + + line := fmt.Sprintf("[%s] [INT] %s%s %s %s", timestamp, internalTool, target, statusIcon, formatActivityDuration(int64(durationMs))) + if errMsg != "" { + line += " " + errMsg + } + return line +} + +// formatPolicyDecisionEvent formats a policy_decision event for display +func formatPolicyDecisionEvent(event map[string]interface{}, timestamp string) string { + server := getStringField(event, "server_name") + tool := getStringField(event, "tool_name") + decision := getStringField(event, "decision") + reason := getStringField(event, "reason") + + statusIcon := "\u2298" // circle with slash for blocked + if decision == "allowed" { + statusIcon = "\u2713" + } + + line := fmt.Sprintf("[%s] [POL] %s:%s %s", timestamp, server, tool, statusIcon) + if reason != "" { + line += " " + reason + } + return line +} + +// formatSystemStartEvent formats a system_start event for display +func formatSystemStartEvent(event map[string]interface{}, timestamp string) string { + version := getStringField(event, "version") + listenAddr := getStringField(event, "listen_address") + startupMs := getIntField(event, "startup_duration_ms") + + return fmt.Sprintf("[%s] [SYS] \u25B6 Started v%s on %s (%s)", timestamp, version, listenAddr, formatActivityDuration(int64(startupMs))) +} + +// formatSystemStopEvent formats a system_stop event for display +func formatSystemStopEvent(event map[string]interface{}, timestamp string) string { + reason := getStringField(event, "reason") + signal := getStringField(event, "signal") + uptimeSec := getIntField(event, "uptime_seconds") + errMsg := getStringField(event, "error_message") + + line := fmt.Sprintf("[%s] [SYS] \u25A0 Stopped: %s", timestamp, reason) + if signal != "" { + line += fmt.Sprintf(" (signal: %s)", signal) + } + if uptimeSec > 0 { + line += fmt.Sprintf(" uptime: %ds", uptimeSec) + } + if errMsg != "" { + line += " error: " + errMsg + } + return line +} + +// formatConfigChangeEvent formats a config_change event for display +func formatConfigChangeEvent(event map[string]interface{}, timestamp string) string { + action := getStringField(event, "action") + entity := getStringField(event, "affected_entity") + source := getStringField(event, "source") + + sourceIcon := formatSourceIndicator(source) + + return fmt.Sprintf("[%s] [%s] \u2699 Config: %s %s", timestamp, sourceIcon, action, entity) +} + +// formatStatusIcon returns a status icon for the given status +func formatStatusIcon(status string) string { + switch status { + case "success": + return "\u2713" // checkmark + case "error": + return "\u2717" // X + case "blocked": + return "\u2298" // circle with slash + default: + return "?" + } } // runActivityShow implements the activity show command diff --git a/frontend/src/stores/system.ts b/frontend/src/stores/system.ts index a6aa2945..b6030dca 100644 --- a/frontend/src/stores/system.ts +++ b/frontend/src/stores/system.ts @@ -221,7 +221,8 @@ export const useSystemStore = defineStore('system', () => { }) // Listen for system lifecycle events (Spec 024) - es.addEventListener('activity.system_start', (event) => { + // Note: Backend sends "activity.system.start" (with dots, not underscores) + es.addEventListener('activity.system.start', (event) => { try { const data = JSON.parse(event.data) console.log('SSE activity.system_start event received:', data) @@ -232,7 +233,8 @@ export const useSystemStore = defineStore('system', () => { } }) - es.addEventListener('activity.system_stop', (event) => { + // Note: Backend sends "activity.system.stop" (with dots, not underscores) + es.addEventListener('activity.system.stop', (event) => { try { const data = JSON.parse(event.data) console.log('SSE activity.system_stop event received:', data) diff --git a/frontend/src/views/Activity.vue b/frontend/src/views/Activity.vue index be10a480..287b9f45 100644 --- a/frontend/src/views/Activity.vue +++ b/frontend/src/views/Activity.vue @@ -806,7 +806,15 @@ const handleActivityEvent = (event: CustomEvent) => { const payload = event.detail // SSE events indicate new activity - refresh the list from API - if (payload && (payload.server_name || payload.tool_name || payload.type)) { + // Check for fields from different event types: + // - tool_call: server_name, tool_name + // - internal_tool_call: internal_tool_name, target_server, target_tool + // - config_change: action, affected_entity + // - system_start/stop: version, listen_address, reason + if (payload && ( + payload.server_name || payload.tool_name || payload.type || + payload.internal_tool_name || payload.action || payload.version || payload.reason + )) { console.log('Activity event received, refreshing from API:', payload) loadActivities() } @@ -817,7 +825,13 @@ const handleActivityCompleted = (event: CustomEvent) => { const payload = event.detail // SSE completed events indicate activity finished - refresh from API - if (payload && (payload.server_name || payload.tool_name || payload.status)) { + // Check for fields from different event types: + // - tool_call: server_name, tool_name, status + // - internal_tool_call: internal_tool_name, target_server, status + if (payload && ( + payload.server_name || payload.tool_name || payload.status || + payload.internal_tool_name || payload.target_server + )) { console.log('Activity completed event received, refreshing from API:', payload) loadActivities() } From 952baa212c058499716a2af557a017fdfeaf0634 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 13 Jan 2026 14:07:57 +0200 Subject: [PATCH 4/6] fix(activity): filter out duplicate call_tool_* entries from activity log (Spec 024) Successful call_tool_* internal tool calls now excluded by default from activity listings (Web UI, CLI, API) to avoid duplicates alongside their corresponding upstream tool_call entries. Failed call_tool_* calls are still shown since they have no corresponding tool_call entry. ## Changes - Add ExcludeCallToolSuccess filter to storage layer (default: true) - Add include_call_tool query parameter to REST API - Filter successful call_tool_* in CLI activity watch command - Add E2E tests for activity log filtering scenarios - Update spec 024, docusaurus docs, and swagger.yaml Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 2 + cmd/mcpproxy/activity_cmd.go | 11 ++ docs/cli/activity-commands.md | 7 + docs/features/activity-log.md | 7 + internal/httpapi/activity.go | 7 + internal/server/e2e_test.go | 245 ++++++++++++++++++++++++++ internal/storage/activity_models.go | 23 ++- oas/swagger.yaml | 6 + specs/024-expand-activity-log/spec.md | 2 + test/e2e-config.json | 6 +- 10 files changed, 311 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 797ed327..508815e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -391,6 +391,8 @@ See `docs/prerelease-builds.md` for download instructions. - Go 1.24 (toolchain go1.24.10) + Cobra (CLI), Chi router (HTTP), Zap (logging), mark3labs/mcp-go (MCP protocol) (020-oauth-login-feedback) - Go 1.24 (toolchain go1.24.10) + Cobra (CLI), Chi router (HTTP), Zap (logging), google/uuid (ID generation) (021-request-id-logging) - BBolt database (`~/.mcpproxy/config.db`) - activity log extended with request_id field (021-request-id-logging) +- Go 1.24 (toolchain go1.24.10) + TypeScript 5.x / Vue 3.5 + Cobra CLI, Chi router, BBolt storage, Zap logging, mark3labs/mcp-go, Vue 3, Tailwind CSS, DaisyUI (024-expand-activity-log) +- BBolt database (`~/.mcpproxy/config.db`) - ActivityRecord model (024-expand-activity-log) ## Recent Changes - 001-update-version-display: Added Go 1.24 (toolchain go1.24.10) diff --git a/cmd/mcpproxy/activity_cmd.go b/cmd/mcpproxy/activity_cmd.go index 7c36276d..a5feaf6c 100644 --- a/cmd/mcpproxy/activity_cmd.go +++ b/cmd/mcpproxy/activity_cmd.go @@ -932,6 +932,17 @@ func displayActivityEvent(eventType, eventData, outputFormat string) { } } + // Skip successful call_tool_* internal tool calls to avoid duplicates + // These have a corresponding tool_call entry that shows the actual upstream call. + // Failed call_tool_* calls are shown since they have no corresponding tool_call. + if eventCategory == "internal_tool_call" { + internalToolName := getStringField(event, "internal_tool_name") + status := getStringField(event, "status") + if status == "success" && strings.HasPrefix(internalToolName, "call_tool_") { + return + } + } + // Format output based on event type timestamp := time.Now().Format("15:04:05") diff --git a/docs/cli/activity-commands.md b/docs/cli/activity-commands.md index 3bc2aa5b..8f93ed09 100644 --- a/docs/cli/activity-commands.md +++ b/docs/cli/activity-commands.md @@ -220,6 +220,7 @@ Source indicators (`[MCP]`, `[CLI]`, `[API]`) show how the tool call was trigger - Automatically reconnects on connection loss (exponential backoff) - Exits cleanly on SIGINT (Ctrl+C) or SIGTERM - Buffers high-volume events to prevent terminal flooding +- **Filters out successful `call_tool_*` internal tool calls** to avoid duplicates (they have corresponding `tool_call` entries) ### Exit Codes @@ -576,6 +577,12 @@ The activity log captures the following event types: | `quarantine_change` | Server quarantine/unquarantine events | | `server_change` | Server enable/disable/restart events | +:::note Duplicate Filtering for call_tool_* +By default, **successful** `call_tool_*` internal tool calls are filtered out from `activity list`, `activity watch`, and the Web UI because they appear as duplicates alongside their corresponding upstream `tool_call` entries. **Failed** `call_tool_*` calls are always shown since they have no corresponding tool call entry. + +To include all internal tool calls in API responses, use `include_call_tool=true` query parameter. +::: + ### Multi-Type Filtering You can filter by multiple types using comma-separated values: diff --git a/docs/features/activity-log.md b/docs/features/activity-log.md index 8c8ee1d2..2d3ced72 100644 --- a/docs/features/activity-log.md +++ b/docs/features/activity-log.md @@ -69,6 +69,12 @@ Internal tool calls log when internal proxy tools are used: } ``` +:::note Duplicate Filtering +By default, **successful** `call_tool_*` internal tool calls (`call_tool_read`, `call_tool_write`, `call_tool_destructive`) are excluded from activity listings because they appear as duplicates alongside their corresponding upstream `tool_call` entries. **Failed** `call_tool_*` calls are always shown since they have no corresponding upstream tool call entry. + +To include all internal tool calls including successful `call_tool_*`, use `include_call_tool=true` in the API query parameter. +::: + ### Config Change Events Configuration changes are logged for audit trails: @@ -230,6 +236,7 @@ GET /api/v1/activity | `end_time` | string | Filter before this time (RFC3339) | | `limit` | integer | Max records (1-100, default: 50) | | `offset` | integer | Pagination offset (default: 0) | +| `include_call_tool` | boolean | Include successful `call_tool_*` internal tool calls (default: false). By default, successful `call_tool_*` are excluded because they appear as duplicates alongside their upstream `tool_call` entries. Failed `call_tool_*` are always shown. | **Example:** diff --git a/internal/httpapi/activity.go b/internal/httpapi/activity.go index 96a1eece..aa3fdc13 100644 --- a/internal/httpapi/activity.go +++ b/internal/httpapi/activity.go @@ -80,6 +80,12 @@ func parseActivityFilters(r *http.Request) storage.ActivityFilter { filter.RequestID = requestID } + // Include call_tool_* internal tool calls (default: exclude successful ones) + // Set include_call_tool=true to show all internal tool calls including successful call_tool_* + if q.Get("include_call_tool") == "true" { + filter.ExcludeCallToolSuccess = false + } + filter.Validate() return filter } @@ -97,6 +103,7 @@ func parseActivityFilters(r *http.Request) storage.ActivityFilter { // @Param status query string false "Filter by status" Enums(success, error, blocked) // @Param intent_type query string false "Filter by intent operation type (Spec 018)" Enums(read, write, destructive) // @Param request_id query string false "Filter by HTTP request ID for log correlation (Spec 021)" +// @Param include_call_tool query bool false "Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)" // @Param start_time query string false "Filter activities after this time (RFC3339)" // @Param end_time query string false "Filter activities before this time (RFC3339)" // @Param limit query int false "Maximum records to return (1-100, default 50)" diff --git a/internal/server/e2e_test.go b/internal/server/e2e_test.go index 1872754c..86f49706 100644 --- a/internal/server/e2e_test.go +++ b/internal/server/e2e_test.go @@ -1858,6 +1858,251 @@ func TestE2E_RequestID_ActivityFiltering(t *testing.T) { t.Log("✅ All Request ID Activity Filtering E2E tests passed") } +// ============================================================================ +// Activity Log Filtering E2E Tests (Spec 024) +// ============================================================================ +// These tests verify that activity log filtering works correctly, +// including the exclusion of successful call_tool_* internal tool calls. + +// TestE2E_Activity_ExcludeCallToolSuccess verifies that successful call_tool_* +// internal tool calls are excluded by default from activity listings. +// Spec: 024-expand-activity-log +func TestE2E_Activity_ExcludeCallToolSuccess(t *testing.T) { + env := NewTestEnvironment(t) + defer env.Cleanup() + + baseURL := strings.TrimSuffix(env.proxyAddr, "/mcp") + apiKey := "test-api-key-e2e" + + mcpClient := env.CreateProxyClient() + defer mcpClient.Close() + env.ConnectClient(mcpClient) + + ctx := context.Background() + + // Step 1: Make a tool call to generate both tool_call and internal_tool_call records + t.Run("Make tool call to generate activity", func(t *testing.T) { + callRequest := mcp.CallToolRequest{} + callRequest.Params.Name = "call_tool_read" + callRequest.Params.Arguments = map[string]interface{}{ + "name": "test-server-fixture:echo_tool", + "args_json": `{"message": "test-activity-filtering"}`, + "intent": map[string]interface{}{ + "operation_type": "read", + "reason": "E2E test for activity filtering", + }, + } + + result, err := mcpClient.CallTool(ctx, callRequest) + require.NoError(t, err) + // Tool call may fail if test-server-fixture doesn't exist, but that's OK + // The activity will still be logged + t.Logf("Tool call result (may fail, activities still logged): isError=%v", result.IsError) + }) + + // Give some time for activities to be persisted + time.Sleep(100 * time.Millisecond) + + // Step 2: Query activities without include_call_tool (default: exclude successful call_tool_*) + t.Run("Default query excludes successful call_tool_*", func(t *testing.T) { + req, err := http.NewRequest("GET", baseURL+"/api/v1/activity?limit=50", nil) + require.NoError(t, err) + req.Header.Set("X-API-Key", apiKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var activityResp struct { + Success bool `json:"success"` + Data struct { + Activities []struct { + ID string `json:"id"` + Type string `json:"type"` + ToolName string `json:"tool_name"` + Status string `json:"status"` + } `json:"activities"` + Total int `json:"total"` + } `json:"data"` + } + err = json.NewDecoder(resp.Body).Decode(&activityResp) + require.NoError(t, err) + + // Check that no successful call_tool_* internal_tool_call records are in the response + for _, act := range activityResp.Data.Activities { + if act.Type == "internal_tool_call" && strings.HasPrefix(act.ToolName, "call_tool_") { + // If we find a call_tool_* internal_tool_call, it must be a failure + assert.NotEqual(t, "success", act.Status, + "Successful call_tool_* internal_tool_call should be excluded by default") + } + } + t.Logf("✅ Default query returned %d activities, no successful call_tool_* found", activityResp.Data.Total) + }) + + // Step 3: Query activities with include_call_tool=true (should include all) + t.Run("include_call_tool=true shows all internal tool calls", func(t *testing.T) { + req, err := http.NewRequest("GET", baseURL+"/api/v1/activity?limit=50&include_call_tool=true", nil) + require.NoError(t, err) + req.Header.Set("X-API-Key", apiKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var activityResp struct { + Success bool `json:"success"` + Data struct { + Activities []struct { + ID string `json:"id"` + Type string `json:"type"` + ToolName string `json:"tool_name"` + Status string `json:"status"` + } `json:"activities"` + Total int `json:"total"` + } `json:"data"` + } + err = json.NewDecoder(resp.Body).Decode(&activityResp) + require.NoError(t, err) + + // Just verify the parameter is accepted - may or may not have call_tool_* entries + t.Logf("✅ include_call_tool=true query returned %d activities", activityResp.Data.Total) + }) + + t.Log("✅ All Activity call_tool_* filtering E2E tests passed") +} + +// TestE2E_Activity_MultiTypeFilter verifies that multi-type filtering works correctly. +// Spec: 024-expand-activity-log +func TestE2E_Activity_MultiTypeFilter(t *testing.T) { + env := NewTestEnvironment(t) + defer env.Cleanup() + + baseURL := strings.TrimSuffix(env.proxyAddr, "/mcp") + apiKey := "test-api-key-e2e" + + // Step 1: Query with single type filter + t.Run("Single type filter", func(t *testing.T) { + req, err := http.NewRequest("GET", baseURL+"/api/v1/activity?type=system_start", nil) + require.NoError(t, err) + req.Header.Set("X-API-Key", apiKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var activityResp struct { + Success bool `json:"success"` + Data struct { + Activities []struct { + ID string `json:"id"` + Type string `json:"type"` + } `json:"activities"` + Total int `json:"total"` + } `json:"data"` + } + err = json.NewDecoder(resp.Body).Decode(&activityResp) + require.NoError(t, err) + + // All returned activities should be system_start type + for _, act := range activityResp.Data.Activities { + assert.Equal(t, "system_start", act.Type, "Filtered activities should only be system_start type") + } + t.Logf("✅ Single type filter returned %d system_start activities", activityResp.Data.Total) + }) + + // Step 2: Query with multi-type filter (comma-separated) + t.Run("Multi-type filter with comma separation", func(t *testing.T) { + req, err := http.NewRequest("GET", baseURL+"/api/v1/activity?type=system_start,system_stop,config_change", nil) + require.NoError(t, err) + req.Header.Set("X-API-Key", apiKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var activityResp struct { + Success bool `json:"success"` + Data struct { + Activities []struct { + ID string `json:"id"` + Type string `json:"type"` + } `json:"activities"` + Total int `json:"total"` + } `json:"data"` + } + err = json.NewDecoder(resp.Body).Decode(&activityResp) + require.NoError(t, err) + + // All returned activities should be one of the filtered types + validTypes := map[string]bool{ + "system_start": true, + "system_stop": true, + "config_change": true, + } + for _, act := range activityResp.Data.Activities { + assert.True(t, validTypes[act.Type], "Activity type %s should be in filter list", act.Type) + } + t.Logf("✅ Multi-type filter returned %d activities of types system_start/system_stop/config_change", activityResp.Data.Total) + }) + + t.Log("✅ All Activity multi-type filtering E2E tests passed") +} + +// TestE2E_Activity_Summary verifies that activity summary endpoint works correctly. +// Spec: 024-expand-activity-log +func TestE2E_Activity_Summary(t *testing.T) { + env := NewTestEnvironment(t) + defer env.Cleanup() + + baseURL := strings.TrimSuffix(env.proxyAddr, "/mcp") + apiKey := "test-api-key-e2e" + + t.Run("Get activity summary for 24h period", func(t *testing.T) { + req, err := http.NewRequest("GET", baseURL+"/api/v1/activity/summary?period=24h", nil) + require.NoError(t, err) + req.Header.Set("X-API-Key", apiKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var summaryResp struct { + Success bool `json:"success"` + Data struct { + Period string `json:"period"` + TotalCount int `json:"total_count"` + SuccessCount int `json:"success_count"` + ErrorCount int `json:"error_count"` + BlockedCount int `json:"blocked_count"` + } `json:"data"` + } + err = json.NewDecoder(resp.Body).Decode(&summaryResp) + require.NoError(t, err) + + assert.True(t, summaryResp.Success, "Summary request should succeed") + assert.Equal(t, "24h", summaryResp.Data.Period, "Period should be 24h") + // Total should be sum of success + error + blocked + assert.GreaterOrEqual(t, summaryResp.Data.TotalCount, + summaryResp.Data.SuccessCount+summaryResp.Data.ErrorCount+summaryResp.Data.BlockedCount, + "Total should be >= sum of status counts") + t.Logf("✅ Activity summary: total=%d, success=%d, error=%d, blocked=%d", + summaryResp.Data.TotalCount, summaryResp.Data.SuccessCount, + summaryResp.Data.ErrorCount, summaryResp.Data.BlockedCount) + }) + + t.Log("✅ All Activity summary E2E tests passed") +} + // ============================================================================ // Smart Config Patching E2E Tests (Spec 023, Issues #239, #240) // ============================================================================ diff --git a/internal/storage/activity_models.go b/internal/storage/activity_models.go index b6368b34..4064babb 100644 --- a/internal/storage/activity_models.go +++ b/internal/storage/activity_models.go @@ -2,6 +2,7 @@ package storage import ( "encoding/json" + "strings" "time" ) @@ -96,13 +97,20 @@ type ActivityFilter struct { Offset int // Pagination offset IntentType string // Filter by intent operation type: read, write, destructive (Spec 018) RequestID string // Filter by HTTP request ID for correlation (Spec 021) + + // ExcludeCallToolSuccess filters out successful call_tool_* internal tool calls. + // These appear as duplicates since the actual upstream tool call is also logged. + // Failed call_tool_* calls are still shown (no corresponding tool_call entry). + // Default: true (to avoid duplicate entries in UI/CLI) + ExcludeCallToolSuccess bool } // DefaultActivityFilter returns an ActivityFilter with sensible defaults func DefaultActivityFilter() ActivityFilter { return ActivityFilter{ - Limit: 50, - Offset: 0, + Limit: 50, + Offset: 0, + ExcludeCallToolSuccess: true, // Exclude successful call_tool_* to avoid duplicates } } @@ -176,6 +184,17 @@ func (f *ActivityFilter) Matches(record *ActivityRecord) bool { return false } + // Exclude successful call_tool_* internal tool calls to avoid duplicates + // These have a corresponding tool_call entry that shows the actual upstream call. + // Failed call_tool_* calls are shown since they have no corresponding tool_call. + if f.ExcludeCallToolSuccess { + if record.Type == ActivityTypeInternalToolCall && + record.Status == "success" && + strings.HasPrefix(record.ToolName, "call_tool_") { + return false + } + } + return true } diff --git a/oas/swagger.yaml b/oas/swagger.yaml index 4f5ae037..5389dfac 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -1622,6 +1622,12 @@ paths: name: offset schema: type: integer + - description: Include successful call_tool_* internal tool calls (default false, + excluded to avoid duplicates) + in: query + name: include_call_tool + schema: + type: boolean requestBody: content: application/json: diff --git a/specs/024-expand-activity-log/spec.md b/specs/024-expand-activity-log/spec.md index f186e858..f1529487 100644 --- a/specs/024-expand-activity-log/spec.md +++ b/specs/024-expand-activity-log/spec.md @@ -38,6 +38,8 @@ As an AI agent developer, I want to see all internal MCPProxy tool calls (retrie 3. **Given** an AI agent calls `call_tool_write` or `call_tool_destructive`, **When** the tool completes, **Then** an activity record is created with intent, target server, tool name, and execution duration 4. **Given** code_execution is enabled and used, **When** code execution completes, **Then** an activity record is created with the code executed, input data, and result summary +**Note on Duplicate Filtering**: Successful `call_tool_*` internal tool calls are excluded by default from activity listings (Web UI, CLI, API) because they appear as duplicates alongside their corresponding upstream `tool_call` entries. Failed `call_tool_*` calls are still shown since they have no corresponding upstream tool call entry. Use `include_call_tool=true` query parameter (API) to show all internal tool calls including successful `call_tool_*`. + --- ### User Story 3 - Log Configuration Changes (Priority: P1) diff --git a/test/e2e-config.json b/test/e2e-config.json index ac9975f8..51b6ddba 100644 --- a/test/e2e-config.json +++ b/test/e2e-config.json @@ -1,5 +1,5 @@ { - "listen": ":8123", + "listen": ":8081", "enable_socket": true, "data_dir": "./test-data", "enable_tray": false, @@ -17,7 +17,7 @@ "enabled": true, "quarantined": false, "created": "2025-01-01T00:00:00Z", - "updated": "2026-01-09T13:13:22.292541+02:00" + "updated": "2026-01-12T21:16:48.553309+02:00" } ], "top_k": 10, @@ -49,7 +49,7 @@ "compress": true, "json_format": false }, - "api_key": "8cc73234d967a2889f8066cc89d42741747358f3a7d4225d22fc2a5bbbd9d89e", + "api_key": "c4eecdd97874603f7c0dec8a499154ca60d852b5cc85f5318fbb1c756b317d1d", "read_only_mode": false, "disable_management": false, "allow_server_add": true, From 532a104ec7a08a57584db9766df5377c22ee40b7 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 13 Jan 2026 21:29:18 +0200 Subject: [PATCH 5/6] feat(activity): replace Tool Calls History with Activity Log (Spec 024) - Remove Tool Calls History page (ToolCalls.vue) entirely - Update Dashboard and Sessions "View Calls" links to Activity Log - Activity Log now accepts ?session= query param for filtering - Update sidebar navigation to show Activity Log instead of Tool Call History - Fix internal tool call logging to include arguments and response JSON (retrieve_tools, read_cache, upstream_servers, quarantine_security, search_servers, list_registries now log full request/response data) Co-Authored-By: Claude Opus 4.5 --- frontend/src/components/SidebarNav.vue | 1 - frontend/src/router/index.ts | 8 - frontend/src/views/Activity.vue | 8 + frontend/src/views/Dashboard.vue | 8 +- frontend/src/views/Sessions.vue | 6 +- frontend/src/views/ToolCalls.vue | 853 ------------------------- internal/server/mcp.go | 148 +++-- 7 files changed, 121 insertions(+), 911 deletions(-) delete mode 100644 frontend/src/views/ToolCalls.vue diff --git a/frontend/src/components/SidebarNav.vue b/frontend/src/components/SidebarNav.vue index aa520140..1acd36a9 100644 --- a/frontend/src/components/SidebarNav.vue +++ b/frontend/src/components/SidebarNav.vue @@ -76,7 +76,6 @@ const menuItems = [ { name: 'Servers', path: '/servers' }, { name: 'Secrets', path: '/secrets' }, { name: 'Search', path: '/search' }, - { name: 'Tool Call History', path: '/tool-calls' }, { name: 'Activity Log', path: '/activity' }, { name: 'Repositories', path: '/repositories' }, { name: 'Configuration', path: '/settings' }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 7691045f..9df9190a 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -61,14 +61,6 @@ const router = createRouter({ title: 'Secrets', }, }, - { - path: '/tool-calls', - name: 'tool-calls', - component: () => import('@/views/ToolCalls.vue'), - meta: { - title: 'Tool Call History', - }, - }, { path: '/sessions', name: 'sessions', diff --git a/frontend/src/views/Activity.vue b/frontend/src/views/Activity.vue index 287b9f45..664e63c9 100644 --- a/frontend/src/views/Activity.vue +++ b/frontend/src/views/Activity.vue @@ -546,11 +546,13 @@ - - \ No newline at end of file diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 78ab849e..72949786 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -638,6 +638,18 @@ func (p *MCPProxyServer) handleSearchServers(ctx context.Context, request mcp.Ca tag := request.GetString("tag", "") limit := int(request.GetFloat("limit", 10.0)) // Default limit of 10 + // Build arguments map for activity logging (Spec 024) + args := map[string]interface{}{ + "registry": registry, + "limit": limit, + } + if search != "" { + args["search"] = search + } + if tag != "" { + args["tag"] = tag + } + // Create experiments guesser if repository checking is enabled var guesser *experiments.Guesser if p.config != nil && p.config.CheckServerRepo { @@ -652,7 +664,7 @@ func (p *MCPProxyServer) handleSearchServers(ctx context.Context, request mcp.Ca zap.String("search", search), zap.String("tag", tag), zap.Error(err)) - p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), args, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Search failed: %v", err)), nil } @@ -676,12 +688,12 @@ func (p *MCPProxyServer) handleSearchServers(ctx context.Context, request mcp.Ca jsonResult, err := json.Marshal(response) if err != nil { - p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), args, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Failed to serialize results: %v", err)), nil } - // Spec 024: Emit success event - p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil, nil, nil) + // Spec 024: Emit success event with args and response + p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), args, response, nil) return mcp.NewToolResultText(string(jsonResult)), nil } @@ -711,18 +723,20 @@ func (p *MCPProxyServer) handleListRegistries(ctx context.Context, _ mcp.CallToo }) } - jsonResult, err := json.Marshal(map[string]interface{}{ + response := map[string]interface{}{ "registries": registriesList, "total": len(registriesList), "message": "Available MCP registries. Use 'search_servers' tool with a registry ID to find servers.", - }) + } + + jsonResult, err := json.Marshal(response) if err != nil { p.emitActivityInternalToolCall("list_registries", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Failed to serialize registries: %v", err)), nil } - // Spec 024: Emit success event - p.emitActivityInternalToolCall("list_registries", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil, nil, nil) + // Spec 024: Emit success event with response + p.emitActivityInternalToolCall("list_registries", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil, response, nil) return mcp.NewToolResultText(string(jsonResult)), nil } @@ -751,6 +765,21 @@ func (p *MCPProxyServer) handleRetrieveTools(ctx context.Context, request mcp.Ca debugMode := request.GetBool("debug", false) explainTool := request.GetString("explain_tool", "") + // Build arguments map for activity logging (Spec 024) + args := map[string]interface{}{ + "query": query, + "limit": limit, + } + if includeStats { + args["include_stats"] = true + } + if debugMode { + args["debug"] = true + } + if explainTool != "" { + args["explain_tool"] = explainTool + } + // Validate limit if limit > 100 { limit = 100 @@ -760,7 +789,7 @@ func (p *MCPProxyServer) handleRetrieveTools(ctx context.Context, request mcp.Ca results, err := p.index.Search(query, limit) if err != nil { p.logger.Error("Search failed", zap.String("query", query), zap.Error(err)) - p.emitActivityInternalToolCall("retrieve_tools", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("retrieve_tools", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), args, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Search failed: %v", err)), nil } @@ -861,12 +890,12 @@ func (p *MCPProxyServer) handleRetrieveTools(ctx context.Context, request mcp.Ca jsonResult, err := json.Marshal(response) if err != nil { - p.emitActivityInternalToolCall("retrieve_tools", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("retrieve_tools", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), args, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Failed to serialize results: %v", err)), nil } - // Emit success event (Spec 024) - p.emitActivityInternalToolCall("retrieve_tools", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil, nil, nil) + // Emit success event with args and response (Spec 024) + p.emitActivityInternalToolCall("retrieve_tools", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), args, response, nil) return mcp.NewToolResultText(string(jsonResult)), nil } @@ -1730,16 +1759,24 @@ func (p *MCPProxyServer) handleUpstreamServers(ctx context.Context, request mcp. return mcp.NewToolResultError(fmt.Sprintf("Missing required parameter 'operation': %v", err)), nil } + // Build arguments map for activity logging (Spec 024) + args := map[string]interface{}{ + "operation": operation, + } + if name := request.GetString("name", ""); name != "" { + args["name"] = name + } + // Security checks if p.config.ReadOnlyMode { if operation != operationList { - p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Operation not allowed in read-only mode", time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Operation not allowed in read-only mode", time.Since(startTime).Milliseconds(), args, nil, nil) return mcp.NewToolResultError("Operation not allowed in read-only mode"), nil } } if p.config.DisableManagement { - p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Server management is disabled for security", time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Server management is disabled for security", time.Since(startTime).Milliseconds(), args, nil, nil) return mcp.NewToolResultError("Server management is disabled for security"), nil } @@ -1747,12 +1784,12 @@ func (p *MCPProxyServer) handleUpstreamServers(ctx context.Context, request mcp. switch operation { case operationAdd: if !p.config.AllowServerAdd { - p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Adding servers is not allowed", time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Adding servers is not allowed", time.Since(startTime).Milliseconds(), args, nil, nil) return mcp.NewToolResultError("Adding servers is not allowed"), nil } case operationRemove: if !p.config.AllowServerRemove { - p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Removing servers is not allowed", time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Removing servers is not allowed", time.Since(startTime).Milliseconds(), args, nil, nil) return mcp.NewToolResultError("Removing servers is not allowed"), nil } } @@ -1781,24 +1818,30 @@ func (p *MCPProxyServer) handleUpstreamServers(ctx context.Context, request mcp. case "restart": result, opErr = p.handleRestartUpstream(ctx, request) default: - p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", fmt.Sprintf("Unknown operation: %s", operation), time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", fmt.Sprintf("Unknown operation: %s", operation), time.Since(startTime).Milliseconds(), args, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Unknown operation: %s", operation)), nil } - // Spec 024: Emit activity event based on result + // Extract response text for activity logging (Spec 024) + var responseText string + if result != nil && len(result.Content) > 0 { + if textContent, ok := result.Content[0].(mcp.TextContent); ok { + responseText = textContent.Text + } + } + + // Spec 024: Emit activity event based on result with args and response if opErr != nil { - p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", opErr.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", opErr.Error(), time.Since(startTime).Milliseconds(), args, nil, nil) } else if result != nil && result.IsError { // Extract error message from result if available errMsg := "operation failed" - if len(result.Content) > 0 { - if textContent, ok := result.Content[0].(mcp.TextContent); ok { - errMsg = textContent.Text - } + if responseText != "" { + errMsg = responseText } - p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", errMsg, time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", errMsg, time.Since(startTime).Milliseconds(), args, nil, nil) } else { - p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), args, responseText, nil) } return result, opErr @@ -1821,14 +1864,22 @@ func (p *MCPProxyServer) handleQuarantineSecurity(ctx context.Context, request m return mcp.NewToolResultError(fmt.Sprintf("Missing required parameter 'operation': %v", err)), nil } + // Build arguments map for activity logging (Spec 024) + args := map[string]interface{}{ + "operation": operation, + } + if name := request.GetString("name", ""); name != "" { + args["name"] = name + } + // Security checks if p.config.ReadOnlyMode { - p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", "Quarantine operations not allowed in read-only mode", time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", "Quarantine operations not allowed in read-only mode", time.Since(startTime).Milliseconds(), args, nil, nil) return mcp.NewToolResultError("Quarantine operations not allowed in read-only mode"), nil } if p.config.DisableManagement { - p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", "Server management is disabled for security", time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", "Server management is disabled for security", time.Since(startTime).Milliseconds(), args, nil, nil) return mcp.NewToolResultError("Server management is disabled for security"), nil } @@ -1844,24 +1895,30 @@ func (p *MCPProxyServer) handleQuarantineSecurity(ctx context.Context, request m case "quarantine": result, opErr = p.handleQuarantineUpstream(ctx, request) default: - p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", fmt.Sprintf("Unknown quarantine operation: %s", operation), time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", fmt.Sprintf("Unknown quarantine operation: %s", operation), time.Since(startTime).Milliseconds(), args, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Unknown quarantine operation: %s", operation)), nil } - // Spec 024: Emit activity event based on result + // Extract response text for activity logging (Spec 024) + var responseText string + if result != nil && len(result.Content) > 0 { + if textContent, ok := result.Content[0].(mcp.TextContent); ok { + responseText = textContent.Text + } + } + + // Spec 024: Emit activity event based on result with args and response if opErr != nil { - p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", opErr.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", opErr.Error(), time.Since(startTime).Milliseconds(), args, nil, nil) } else if result != nil && result.IsError { // Extract error message from result if available errMsg := "operation failed" - if len(result.Content) > 0 { - if textContent, ok := result.Content[0].(mcp.TextContent); ok { - errMsg = textContent.Text - } + if responseText != "" { + errMsg = responseText } - p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", errMsg, time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", errMsg, time.Since(startTime).Milliseconds(), args, nil, nil) } else { - p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), args, responseText, nil) } return result, opErr @@ -3295,32 +3352,39 @@ func (p *MCPProxyServer) handleReadCache(ctx context.Context, request mcp.CallTo offset := int(request.GetFloat("offset", 0)) limit := int(request.GetFloat("limit", 50)) + // Build arguments map for activity logging (Spec 024) + args := map[string]interface{}{ + "key": key, + "offset": offset, + "limit": limit, + } + // Validate parameters if offset < 0 { - p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", "Offset must be non-negative", time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", "Offset must be non-negative", time.Since(startTime).Milliseconds(), args, nil, nil) return mcp.NewToolResultError("Offset must be non-negative"), nil } if limit <= 0 || limit > 1000 { - p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", "Limit must be between 1 and 1000", time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", "Limit must be between 1 and 1000", time.Since(startTime).Milliseconds(), args, nil, nil) return mcp.NewToolResultError("Limit must be between 1 and 1000"), nil } // Retrieve cached data response, err := p.cacheManager.GetRecords(key, offset, limit) if err != nil { - p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), args, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Failed to retrieve cached data: %v", err)), nil } // Serialize response jsonResult, err := json.Marshal(response) if err != nil { - p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil, nil, nil) + p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), args, nil, nil) return mcp.NewToolResultError(fmt.Sprintf("Failed to serialize response: %v", err)), nil } - // Spec 024: Emit success event - p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil, nil, nil) + // Spec 024: Emit success event with args and response + p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), args, response, nil) return mcp.NewToolResultText(string(jsonResult)), nil } From 7886ccb95ec822cbd1121cf0759d7234a132408b Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 13 Jan 2026 21:47:14 +0200 Subject: [PATCH 6/6] chore: regenerate OpenAPI spec Co-Authored-By: Claude Opus 4.5 --- oas/docs.go | 2 +- oas/swagger.yaml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/oas/docs.go b/oas/docs.go index 3f1e4d8b..3397f603 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":{"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"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_response_limit":{"type":"integer"},"tools_limit":{"type":"integer"},"top_k":{"type":"integer"},"tray_endpoint":{"description":"Tray endpoint override (unix:// or npipe://)","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":"Feature flags for modular functionality","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.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"},"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"},"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"},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"id":{"description":"Unique identifier (ULID format)","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.Diagnostics":{"properties":{"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.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"},"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"},"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":"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/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/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 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/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/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 5389dfac..6cb0a21d 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -1602,6 +1602,12 @@ paths: 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 activities after this time (RFC3339) in: query name: start_time @@ -1622,12 +1628,6 @@ paths: name: offset schema: type: integer - - description: Include successful call_tool_* internal tool calls (default false, - excluded to avoid duplicates) - in: query - name: include_call_tool - schema: - type: boolean requestBody: content: application/json:
TimeTypeServer + Time {{ getSortIndicator('timestamp') }} + + Type {{ getSortIndicator('type') }} + + Server {{ getSortIndicator('server_name') }} + DetailsStatusDurationIntent + Status {{ getSortIndicator('status') }} + + Duration {{ getSortIndicator('duration_ms') }} +
+
+ + {{ getIntentIcon(activity.metadata.intent.operation_type) }} + {{ activity.metadata.intent.operation_type }} + +
+ - +
- Showing {{ (currentPage - 1) * pageSize + 1 }}-{{ Math.min(currentPage * pageSize, filteredActivities.length) }} of {{ filteredActivities.length }} + Showing {{ (currentPage - 1) * pageSize + 1 }}-{{ Math.min(currentPage * pageSize, sortedActivities.length) }} of {{ sortedActivities.length }}
+ +
+

+ + + + Policy Decision +

+
+
+
+ Decision: + {{ selectedActivity.metadata?.decision || selectedActivity.status || 'Blocked' }} +
+
+ Reason: + {{ selectedActivity.metadata.reason }} +
+
+ Policy Rule: + {{ selectedActivity.metadata.policy_rule }} +
+
+ Tool call was blocked by security policy +
+
+
+
+

@@ -419,6 +514,15 @@

+ + +
+

+ Additional Details + JSON +

+ +
@@ -445,16 +549,34 @@ const showDetailDrawer = ref(false) const autoRefresh = ref(true) // Filters -const filterType = ref('') +const selectedTypes = ref([]) const filterServer = ref('') const filterStatus = ref('') const filterStartDate = ref('') const filterEndDate = ref('') +// Activity types configuration (Spec 024: includes new types) +const activityTypes = [ + { value: 'tool_call', label: 'Tool Call', icon: '🔧' }, + { value: 'system_start', label: 'System Start', icon: '🚀' }, + { value: 'system_stop', label: 'System Stop', icon: '🛑' }, + { value: 'internal_tool_call', label: 'Internal Tool Call', icon: '⚙️' }, + { value: 'config_change', label: 'Config Change', icon: '⚡' }, + { value: 'policy_decision', label: 'Policy Decision', icon: '🛡️' }, + { value: 'quarantine_change', label: 'Quarantine Change', icon: '⚠️' }, + { value: 'server_change', label: 'Server Change', icon: '🔄' }, +] + // Pagination const currentPage = ref(1) const pageSize = ref(25) +// Sorting (Spec 024: US6) +type SortColumn = 'timestamp' | 'type' | 'server_name' | 'status' | 'duration_ms' +type SortDirection = 'asc' | 'desc' +const sortColumn = ref('timestamp') +const sortDirection = ref('desc') // Default: newest first + // Computed const availableServers = computed(() => { const servers = new Set() @@ -465,14 +587,15 @@ const availableServers = computed(() => { }) const hasActiveFilters = computed(() => { - return filterType.value || filterServer.value || filterStatus.value || filterStartDate.value || filterEndDate.value + return selectedTypes.value.length > 0 || filterServer.value || filterStatus.value || filterStartDate.value || filterEndDate.value }) const filteredActivities = computed(() => { let result = activities.value - if (filterType.value) { - result = result.filter(a => a.type === filterType.value) + // Multi-type filter (Spec 024): OR logic - show activities matching ANY selected type + if (selectedTypes.value.length > 0) { + result = result.filter(a => selectedTypes.value.includes(a.type)) } if (filterServer.value) { result = result.filter(a => a.server_name === filterServer.value) @@ -492,11 +615,41 @@ const filteredActivities = computed(() => { return result }) -const totalPages = computed(() => Math.ceil(filteredActivities.value.length / pageSize.value)) +// Sorted activities (Spec 024: US6) +const sortedActivities = computed(() => { + const result = [...filteredActivities.value] + const col = sortColumn.value + const dir = sortDirection.value + + result.sort((a, b) => { + let aVal: string | number | undefined + let bVal: string | number | undefined + + if (col === 'timestamp') { + aVal = new Date(a.timestamp).getTime() + bVal = new Date(b.timestamp).getTime() + } else if (col === 'duration_ms') { + aVal = a.duration_ms ?? 0 + bVal = b.duration_ms ?? 0 + } else { + aVal = a[col] ?? '' + bVal = b[col] ?? '' + } + + if (typeof aVal === 'string' && typeof bVal === 'string') { + return dir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal) + } + return dir === 'asc' ? (aVal as number) - (bVal as number) : (bVal as number) - (aVal as number) + }) + + return result +}) + +const totalPages = computed(() => Math.ceil(sortedActivities.value.length / pageSize.value)) const paginatedActivities = computed(() => { const start = (currentPage.value - 1) * pageSize.value - return filteredActivities.value.slice(start, start + pageSize.value) + return sortedActivities.value.slice(start, start + pageSize.value) }) // Load activities @@ -528,7 +681,7 @@ const loadActivities = async () => { // Clear filters const clearFilters = () => { - filterType.value = '' + selectedTypes.value = [] filterServer.value = '' filterStatus.value = '' filterStartDate.value = '' @@ -536,6 +689,39 @@ const clearFilters = () => { currentPage.value = 1 } +// Toggle type filter (Spec 024: multi-select support) +const toggleTypeFilter = (type: string) => { + const index = selectedTypes.value.indexOf(type) + if (index >= 0) { + selectedTypes.value.splice(index, 1) + } else { + selectedTypes.value.push(type) + } +} + +// Clear type filter only +const clearTypeFilter = () => { + selectedTypes.value = [] +} + +// Sort by column (Spec 024: US6) +const sortBy = (column: SortColumn) => { + if (sortColumn.value === column) { + // Toggle direction if same column + sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc' + } else { + // New column - default to descending for timestamp/duration, ascending for others + sortColumn.value = column + sortDirection.value = column === 'timestamp' || column === 'duration_ms' ? 'desc' : 'asc' + } +} + +// Get sort indicator for column header +const getSortIndicator = (column: SortColumn): string => { + if (sortColumn.value !== column) return '' + return sortDirection.value === 'asc' ? '↑' : '↓' +} + // Select activity for detail view const selectActivity = (activity: ActivityRecord) => { selectedActivity.value = activity @@ -551,7 +737,8 @@ const closeDetailDrawer = () => { const exportActivities = (format: 'json' | 'csv') => { const url = api.getActivityExportUrl({ format, - type: filterType.value || undefined, + // Spec 024: Pass comma-separated types for multi-type filter + type: selectedTypes.value.length > 0 ? selectedTypes.value.join(',') : undefined, server: filterServer.value || undefined, status: filterStatus.value || undefined, }) @@ -600,8 +787,13 @@ const formatRelativeTime = (timestamp: string): string => { } const formatType = (type: string): string => { + // Spec 024: Include new activity types const typeLabels: Record = { 'tool_call': 'Tool Call', + 'system_start': 'System Start', + 'system_stop': 'System Stop', + 'internal_tool_call': 'Internal Tool Call', + 'config_change': 'Config Change', 'policy_decision': 'Policy Decision', 'quarantine_change': 'Quarantine Change', 'server_change': 'Server Change' @@ -610,8 +802,13 @@ const formatType = (type: string): string => { } const getTypeIcon = (type: string): string => { + // Spec 024: Include new activity types const typeIcons: Record = { 'tool_call': '🔧', + 'system_start': '🚀', + 'system_stop': '🛑', + 'internal_tool_call': '⚙️', + 'config_change': '⚡', 'policy_decision': '🛡️', 'quarantine_change': '⚠️', 'server_change': '🔄' @@ -670,10 +867,37 @@ const getIntentBadgeClass = (operationType: string): string => { return classes[operationType] || 'badge-ghost' } +// Check if there's additional metadata beyond what we show in dedicated sections +const hasAdditionalMetadata = (activity: ActivityRecord): boolean => { + if (!activity.metadata) return false + + // Filter out fields we already show in dedicated sections + const shownFields = ['intent', 'decision', 'reason', 'policy_rule'] + const additionalKeys = Object.keys(activity.metadata).filter(k => !shownFields.includes(k)) + + return additionalKeys.length > 0 +} + +// Get metadata excluding fields already shown in dedicated sections +const getAdditionalMetadata = (activity: ActivityRecord): Record => { + if (!activity.metadata) return {} + + const shownFields = ['intent', 'decision', 'reason', 'policy_rule'] + const result: Record = {} + + for (const [key, value] of Object.entries(activity.metadata)) { + if (!shownFields.includes(key)) { + result[key] = value + } + } + + return result +} + // Reset page when filters change -watch([filterType, filterServer, filterStatus, filterStartDate, filterEndDate], () => { +watch([selectedTypes, filterServer, filterStatus, filterStartDate, filterEndDate], () => { currentPage.value = 1 -}) +}, { deep: true }) // Keyboard handler for closing drawer const handleKeydown = (event: KeyboardEvent) => { diff --git a/internal/httpapi/activity.go b/internal/httpapi/activity.go index 3217bca9..96a1eece 100644 --- a/internal/httpapi/activity.go +++ b/internal/httpapi/activity.go @@ -19,9 +19,9 @@ func parseActivityFilters(r *http.Request) storage.ActivityFilter { filter := storage.DefaultActivityFilter() q := r.URL.Query() - // Type filter + // Type filter (Spec 024: supports comma-separated multiple types) if typeStr := q.Get("type"); typeStr != "" { - filter.Type = typeStr + filter.Types = strings.Split(typeStr, ",") } // Server filter @@ -90,7 +90,7 @@ func parseActivityFilters(r *http.Request) storage.ActivityFilter { // @Tags Activity // @Accept json // @Produce json -// @Param type query string false "Filter by activity type" Enums(tool_call, policy_decision, quarantine_change, server_change) +// @Param type query string false "Filter by activity type(s), comma-separated for multiple (Spec 024)" Enums(tool_call, policy_decision, quarantine_change, server_change, system_start, system_stop, internal_tool_call, config_change) // @Param server query string false "Filter by server name" // @Param tool query string false "Filter by tool name" // @Param session_id query string false "Filter by MCP session ID" diff --git a/internal/httpapi/activity_test.go b/internal/httpapi/activity_test.go index 7a723368..bddea2e7 100644 --- a/internal/httpapi/activity_test.go +++ b/internal/httpapi/activity_test.go @@ -29,10 +29,28 @@ func TestParseActivityFilters(t *testing.T) { }, }, { - name: "type filter", + name: "single type filter", query: "type=tool_call", expected: storage.ActivityFilter{ - Type: "tool_call", + Types: []string{"tool_call"}, + Limit: 50, + Offset: 0, + }, + }, + { + name: "multiple types filter (Spec 024)", + query: "type=tool_call,policy_decision", + expected: storage.ActivityFilter{ + Types: []string{"tool_call", "policy_decision"}, + Limit: 50, + Offset: 0, + }, + }, + { + name: "all new event types (Spec 024)", + query: "type=system_start,system_stop,internal_tool_call,config_change", + expected: storage.ActivityFilter{ + Types: []string{"system_start", "system_stop", "internal_tool_call", "config_change"}, Limit: 50, Offset: 0, }, @@ -90,10 +108,10 @@ func TestParseActivityFilters(t *testing.T) { }, }, { - name: "multiple filters", + name: "multiple filters with types", query: "type=tool_call&server=github&status=success&limit=20", expected: storage.ActivityFilter{ - Type: "tool_call", + Types: []string{"tool_call"}, Server: "github", Status: "success", Limit: 20, @@ -107,7 +125,7 @@ func TestParseActivityFilters(t *testing.T) { req := httptest.NewRequest("GET", "/api/v1/activity?"+tt.query, nil) filter := parseActivityFilters(req) - assert.Equal(t, tt.expected.Type, filter.Type) + assert.Equal(t, tt.expected.Types, filter.Types) assert.Equal(t, tt.expected.Server, filter.Server) assert.Equal(t, tt.expected.Tool, filter.Tool) assert.Equal(t, tt.expected.SessionID, filter.SessionID) diff --git a/internal/runtime/activity_service.go b/internal/runtime/activity_service.go index 00d627cd..569e7ed4 100644 --- a/internal/runtime/activity_service.go +++ b/internal/runtime/activity_service.go @@ -165,6 +165,15 @@ func (s *ActivityService) handleEvent(evt Event) { zap.String("tool_name", getStringPayload(evt.Payload, "tool_name")), zap.String("session_id", getStringPayload(evt.Payload, "session_id")), zap.String("request_id", getStringPayload(evt.Payload, "request_id"))) + // Spec 024: System lifecycle events + case EventTypeActivitySystemStart: + s.handleSystemStart(evt) + case EventTypeActivitySystemStop: + s.handleSystemStop(evt) + case EventTypeActivityInternalToolCall: + s.handleInternalToolCall(evt) + case EventTypeActivityConfigChange: + s.handleConfigChange(evt) default: // Ignore other event types } @@ -293,6 +302,182 @@ func (s *ActivityService) handleQuarantineChange(evt Event) { } } +// handleSystemStart persists a system start event (Spec 024). +func (s *ActivityService) handleSystemStart(evt Event) { + version := getStringPayload(evt.Payload, "version") + listenAddress := getStringPayload(evt.Payload, "listen_address") + startupDurationMs := getInt64Payload(evt.Payload, "startup_duration_ms") + configPath := getStringPayload(evt.Payload, "config_path") + + record := &storage.ActivityRecord{ + Type: storage.ActivityTypeSystemStart, + Source: storage.ActivitySourceAPI, // System events come from the API server + Status: "success", + Metadata: map[string]interface{}{ + "version": version, + "listen_address": listenAddress, + "startup_duration_ms": startupDurationMs, + "config_path": configPath, + }, + Timestamp: evt.Timestamp, + } + + if err := s.storage.SaveActivity(record); err != nil { + s.logger.Error("Failed to save system start activity", + zap.Error(err), + zap.String("version", version)) + } else { + s.logger.Info("System start activity recorded", + zap.String("id", record.ID), + zap.String("version", version), + zap.Int64("startup_duration_ms", startupDurationMs)) + } +} + +// handleSystemStop persists a system stop event (Spec 024). +func (s *ActivityService) handleSystemStop(evt Event) { + reason := getStringPayload(evt.Payload, "reason") + signal := getStringPayload(evt.Payload, "signal") + uptimeSeconds := getInt64Payload(evt.Payload, "uptime_seconds") + errorMsg := getStringPayload(evt.Payload, "error_message") + + status := "success" + if errorMsg != "" { + status = "error" + } + + record := &storage.ActivityRecord{ + Type: storage.ActivityTypeSystemStop, + Source: storage.ActivitySourceAPI, + Status: status, + ErrorMessage: errorMsg, + Metadata: map[string]interface{}{ + "reason": reason, + "signal": signal, + "uptime_seconds": uptimeSeconds, + }, + Timestamp: evt.Timestamp, + } + + if err := s.storage.SaveActivity(record); err != nil { + s.logger.Error("Failed to save system stop activity", + zap.Error(err), + zap.String("reason", reason)) + } else { + s.logger.Info("System stop activity recorded", + zap.String("id", record.ID), + zap.String("reason", reason), + zap.Int64("uptime_seconds", uptimeSeconds)) + } +} + +// handleInternalToolCall persists an internal tool call event (Spec 024). +func (s *ActivityService) handleInternalToolCall(evt Event) { + internalToolName := getStringPayload(evt.Payload, "internal_tool_name") + targetServer := getStringPayload(evt.Payload, "target_server") + targetTool := getStringPayload(evt.Payload, "target_tool") + toolVariant := getStringPayload(evt.Payload, "tool_variant") + sessionID := getStringPayload(evt.Payload, "session_id") + requestID := getStringPayload(evt.Payload, "request_id") + status := getStringPayload(evt.Payload, "status") + errorMsg := getStringPayload(evt.Payload, "error_message") + durationMs := getInt64Payload(evt.Payload, "duration_ms") + intent := getMapPayload(evt.Payload, "intent") + + metadata := map[string]interface{}{ + "internal_tool_name": internalToolName, + } + if targetServer != "" { + metadata["target_server"] = targetServer + } + if targetTool != "" { + metadata["target_tool"] = targetTool + } + if toolVariant != "" { + metadata["tool_variant"] = toolVariant + } + if intent != nil { + metadata["intent"] = intent + } + + record := &storage.ActivityRecord{ + Type: storage.ActivityTypeInternalToolCall, + Source: storage.ActivitySourceMCP, + ToolName: internalToolName, + ServerName: targetServer, + Status: status, + ErrorMessage: errorMsg, + DurationMs: durationMs, + Metadata: metadata, + Timestamp: evt.Timestamp, + SessionID: sessionID, + RequestID: requestID, + } + + if err := s.storage.SaveActivity(record); err != nil { + s.logger.Error("Failed to save internal tool call activity", + zap.Error(err), + zap.String("internal_tool_name", internalToolName)) + } else { + s.logger.Debug("Internal tool call activity recorded", + zap.String("id", record.ID), + zap.String("internal_tool_name", internalToolName), + zap.String("status", status)) + } +} + +// handleConfigChange persists a config change event (Spec 024). +func (s *ActivityService) handleConfigChange(evt Event) { + action := getStringPayload(evt.Payload, "action") + affectedEntity := getStringPayload(evt.Payload, "affected_entity") + source := getStringPayload(evt.Payload, "source") + + var activitySource storage.ActivitySource + switch source { + case "cli": + activitySource = storage.ActivitySourceCLI + case "mcp": + activitySource = storage.ActivitySourceMCP + default: + activitySource = storage.ActivitySourceAPI + } + + metadata := map[string]interface{}{ + "action": action, + "affected_entity": affectedEntity, + } + if changedFields := getSlicePayload(evt.Payload, "changed_fields"); len(changedFields) > 0 { + metadata["changed_fields"] = changedFields + } + if prevValues := getMapPayload(evt.Payload, "previous_values"); prevValues != nil { + metadata["previous_values"] = prevValues + } + if newValues := getMapPayload(evt.Payload, "new_values"); newValues != nil { + metadata["new_values"] = newValues + } + + record := &storage.ActivityRecord{ + Type: storage.ActivityTypeConfigChange, + Source: activitySource, + ServerName: affectedEntity, + Status: "success", + Metadata: metadata, + Timestamp: evt.Timestamp, + } + + if err := s.storage.SaveActivity(record); err != nil { + s.logger.Error("Failed to save config change activity", + zap.Error(err), + zap.String("action", action), + zap.String("affected_entity", affectedEntity)) + } else { + s.logger.Info("Config change activity recorded", + zap.String("id", record.ID), + zap.String("action", action), + zap.String("affected_entity", affectedEntity)) + } +} + // Helper functions to extract payload values safely func getStringPayload(payload map[string]any, key string) string { @@ -340,3 +525,31 @@ func getMapPayload(payload map[string]any, key string) map[string]interface{} { return nil } +func getSlicePayload(payload map[string]any, key string) []string { + if v, ok := payload[key]; ok { + if s, ok := v.([]string); ok { + return s + } + // Also handle []interface{} and convert to []string + if arr, ok := v.([]interface{}); ok { + result := make([]string, 0, len(arr)) + for _, item := range arr { + if str, ok := item.(string); ok { + result = append(result, str) + } + } + return result + } + if arr, ok := v.([]any); ok { + result := make([]string, 0, len(arr)) + for _, item := range arr { + if str, ok := item.(string); ok { + result = append(result, str) + } + } + return result + } + } + return nil +} + diff --git a/internal/runtime/activity_service_test.go b/internal/runtime/activity_service_test.go new file mode 100644 index 00000000..e8d26827 --- /dev/null +++ b/internal/runtime/activity_service_test.go @@ -0,0 +1,271 @@ +package runtime + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// TestEmitActivitySystemStart verifies system_start event emission (Spec 024) +func TestEmitActivitySystemStart(t *testing.T) { + logger, err := zap.NewDevelopment() + require.NoError(t, err) + defer logger.Sync() + + rt := &Runtime{ + logger: logger, + eventSubs: make(map[chan Event]struct{}), + } + + // Subscribe to events + eventChan := rt.SubscribeEvents() + defer rt.UnsubscribeEvents(eventChan) + + done := make(chan Event, 1) + + // Listen for activity.system.start event + go func() { + select { + case evt := <-eventChan: + if evt.Type == EventTypeActivitySystemStart { + done <- evt + } + case <-time.After(2 * time.Second): + t.Log("Timeout waiting for activity.system.start event") + } + }() + + // Emit system start event + rt.EmitActivitySystemStart("v1.2.3", "127.0.0.1:8080", 150, "/path/to/config.json") + + // Wait for event + select { + case evt := <-done: + assert.Equal(t, EventTypeActivitySystemStart, evt.Type, "Event type should be activity.system.start") + assert.NotNil(t, evt.Payload, "Event payload should not be nil") + assert.Equal(t, "v1.2.3", evt.Payload["version"], "Event should contain version") + assert.Equal(t, "127.0.0.1:8080", evt.Payload["listen_address"], "Event should contain listen_address") + assert.Equal(t, int64(150), evt.Payload["startup_duration_ms"], "Event should contain startup_duration_ms") + assert.Equal(t, "/path/to/config.json", evt.Payload["config_path"], "Event should contain config_path") + assert.NotZero(t, evt.Timestamp, "Event should have a timestamp") + case <-time.After(2 * time.Second): + t.Fatal("Did not receive activity.system.start event within timeout") + } +} + +// TestEmitActivitySystemStop verifies system_stop event emission (Spec 024) +func TestEmitActivitySystemStop(t *testing.T) { + logger, err := zap.NewDevelopment() + require.NoError(t, err) + defer logger.Sync() + + rt := &Runtime{ + logger: logger, + eventSubs: make(map[chan Event]struct{}), + } + + // Subscribe to events + eventChan := rt.SubscribeEvents() + defer rt.UnsubscribeEvents(eventChan) + + done := make(chan Event, 1) + + // Listen for activity.system.stop event + go func() { + select { + case evt := <-eventChan: + if evt.Type == EventTypeActivitySystemStop { + done <- evt + } + case <-time.After(2 * time.Second): + t.Log("Timeout waiting for activity.system.stop event") + } + }() + + // Emit system stop event + rt.EmitActivitySystemStop("signal", "SIGTERM", 3600, "") + + // Wait for event + select { + case evt := <-done: + assert.Equal(t, EventTypeActivitySystemStop, evt.Type, "Event type should be activity.system.stop") + assert.NotNil(t, evt.Payload, "Event payload should not be nil") + assert.Equal(t, "signal", evt.Payload["reason"], "Event should contain reason") + assert.Equal(t, "SIGTERM", evt.Payload["signal"], "Event should contain signal") + assert.Equal(t, int64(3600), evt.Payload["uptime_seconds"], "Event should contain uptime_seconds") + assert.Equal(t, "", evt.Payload["error_message"], "Event should contain error_message") + assert.NotZero(t, evt.Timestamp, "Event should have a timestamp") + case <-time.After(2 * time.Second): + t.Fatal("Did not receive activity.system.stop event within timeout") + } +} + +// TestEmitActivitySystemStop_WithError verifies system_stop event includes error info +func TestEmitActivitySystemStop_WithError(t *testing.T) { + logger, err := zap.NewDevelopment() + require.NoError(t, err) + defer logger.Sync() + + rt := &Runtime{ + logger: logger, + eventSubs: make(map[chan Event]struct{}), + } + + // Subscribe to events + eventChan := rt.SubscribeEvents() + defer rt.UnsubscribeEvents(eventChan) + + done := make(chan Event, 1) + + // Listen for activity.system.stop event + go func() { + select { + case evt := <-eventChan: + if evt.Type == EventTypeActivitySystemStop { + done <- evt + } + case <-time.After(2 * time.Second): + t.Log("Timeout waiting for activity.system.stop event") + } + }() + + // Emit system stop event with error + rt.EmitActivitySystemStop("error", "", 120, "database connection lost") + + // Wait for event + select { + case evt := <-done: + assert.Equal(t, EventTypeActivitySystemStop, evt.Type) + assert.Equal(t, "error", evt.Payload["reason"]) + assert.Equal(t, "", evt.Payload["signal"]) + assert.Equal(t, int64(120), evt.Payload["uptime_seconds"]) + assert.Equal(t, "database connection lost", evt.Payload["error_message"]) + case <-time.After(2 * time.Second): + t.Fatal("Did not receive activity.system.stop event within timeout") + } +} + +// TestEmitActivityInternalToolCall verifies internal_tool_call event emission (Spec 024) +func TestEmitActivityInternalToolCall(t *testing.T) { + logger, err := zap.NewDevelopment() + require.NoError(t, err) + defer logger.Sync() + + rt := &Runtime{ + logger: logger, + eventSubs: make(map[chan Event]struct{}), + } + + // Subscribe to events + eventChan := rt.SubscribeEvents() + defer rt.UnsubscribeEvents(eventChan) + + done := make(chan Event, 1) + + // Listen for activity.internal_tool_call.completed event + go func() { + select { + case evt := <-eventChan: + if evt.Type == EventTypeActivityInternalToolCall { + done <- evt + } + case <-time.After(2 * time.Second): + t.Log("Timeout waiting for activity.internal_tool_call.completed event") + } + }() + + // Emit internal tool call event + intent := map[string]interface{}{ + "operation_type": "read", + "data_sensitivity": "public", + } + rt.EmitActivityInternalToolCall( + "call_tool_read", + "github", + "get_user", + "call_tool_read", + "sess-123", + "req-456", + "success", + "", + 250, + intent, + ) + + // Wait for event + select { + case evt := <-done: + assert.Equal(t, EventTypeActivityInternalToolCall, evt.Type) + assert.Equal(t, "call_tool_read", evt.Payload["internal_tool_name"]) + assert.Equal(t, "github", evt.Payload["target_server"]) + assert.Equal(t, "get_user", evt.Payload["target_tool"]) + assert.Equal(t, "call_tool_read", evt.Payload["tool_variant"]) + assert.Equal(t, "sess-123", evt.Payload["session_id"]) + assert.Equal(t, "req-456", evt.Payload["request_id"]) + assert.Equal(t, "success", evt.Payload["status"]) + assert.Equal(t, "", evt.Payload["error_message"]) + assert.Equal(t, int64(250), evt.Payload["duration_ms"]) + assert.NotNil(t, evt.Payload["intent"]) + case <-time.After(2 * time.Second): + t.Fatal("Did not receive activity.internal_tool_call.completed event within timeout") + } +} + +// TestEmitActivityConfigChange verifies config_change event emission (Spec 024) +func TestEmitActivityConfigChange(t *testing.T) { + logger, err := zap.NewDevelopment() + require.NoError(t, err) + defer logger.Sync() + + rt := &Runtime{ + logger: logger, + eventSubs: make(map[chan Event]struct{}), + } + + // Subscribe to events + eventChan := rt.SubscribeEvents() + defer rt.UnsubscribeEvents(eventChan) + + done := make(chan Event, 1) + + // Listen for activity.config_change event + go func() { + select { + case evt := <-eventChan: + if evt.Type == EventTypeActivityConfigChange { + done <- evt + } + case <-time.After(2 * time.Second): + t.Log("Timeout waiting for activity.config_change event") + } + }() + + // Emit config change event + prevValues := map[string]interface{}{"enabled": true} + newValues := map[string]interface{}{"enabled": false} + rt.EmitActivityConfigChange( + "server_updated", + "github", + "mcp", + []string{"enabled"}, + prevValues, + newValues, + ) + + // Wait for event + select { + case evt := <-done: + assert.Equal(t, EventTypeActivityConfigChange, evt.Type) + assert.Equal(t, "server_updated", evt.Payload["action"]) + assert.Equal(t, "github", evt.Payload["affected_entity"]) + assert.Equal(t, "mcp", evt.Payload["source"]) + assert.NotNil(t, evt.Payload["changed_fields"]) + assert.NotNil(t, evt.Payload["previous_values"]) + assert.NotNil(t, evt.Payload["new_values"]) + case <-time.After(2 * time.Second): + t.Fatal("Did not receive activity.config_change event within timeout") + } +} diff --git a/internal/runtime/event_bus.go b/internal/runtime/event_bus.go index f6764728..33344307 100644 --- a/internal/runtime/event_bus.go +++ b/internal/runtime/event_bus.go @@ -148,3 +148,74 @@ func (r *Runtime) EmitActivityQuarantineChange(serverName string, quarantined bo } r.publishEvent(newEvent(EventTypeActivityQuarantineChange, payload)) } + +// EmitActivitySystemStart emits an event when MCPProxy server starts (Spec 024). +func (r *Runtime) EmitActivitySystemStart(version, listenAddress string, startupDurationMs int64, configPath string) { + payload := map[string]any{ + "version": version, + "listen_address": listenAddress, + "startup_duration_ms": startupDurationMs, + "config_path": configPath, + } + r.publishEvent(newEvent(EventTypeActivitySystemStart, payload)) +} + +// EmitActivitySystemStop emits an event when MCPProxy server stops (Spec 024). +func (r *Runtime) EmitActivitySystemStop(reason, signal string, uptimeSeconds int64, errorMsg string) { + payload := map[string]any{ + "reason": reason, + "signal": signal, + "uptime_seconds": uptimeSeconds, + "error_message": errorMsg, + } + r.publishEvent(newEvent(EventTypeActivitySystemStop, payload)) +} + +// EmitActivityInternalToolCall emits an event when an internal tool is called (Spec 024). +// internalToolName is the name of the internal tool (retrieve_tools, call_tool_read, etc.) +// targetServer and targetTool are used for call_tool_* handlers +// intent is the intent declaration metadata +func (r *Runtime) EmitActivityInternalToolCall(internalToolName, targetServer, targetTool, toolVariant, sessionID, requestID, status, errorMsg string, durationMs int64, intent map[string]interface{}) { + 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 intent != nil { + payload["intent"] = intent + } + 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" +func (r *Runtime) EmitActivityConfigChange(action, affectedEntity, source string, changedFields []string, previousValues, newValues map[string]interface{}) { + payload := map[string]any{ + "action": action, + "affected_entity": affectedEntity, + "source": source, + } + if len(changedFields) > 0 { + payload["changed_fields"] = changedFields + } + if previousValues != nil { + payload["previous_values"] = previousValues + } + if newValues != nil { + payload["new_values"] = newValues + } + r.publishEvent(newEvent(EventTypeActivityConfigChange, payload)) +} diff --git a/internal/runtime/events.go b/internal/runtime/events.go index 68e3246e..4399eeed 100644 --- a/internal/runtime/events.go +++ b/internal/runtime/events.go @@ -28,6 +28,16 @@ const ( EventTypeActivityPolicyDecision EventType = "activity.policy_decision" // EventTypeActivityQuarantineChange is emitted when a server's quarantine state changes. EventTypeActivityQuarantineChange EventType = "activity.quarantine_change" + + // Spec 024: Expanded Activity Log events + // EventTypeActivitySystemStart is emitted when MCPProxy server starts. + EventTypeActivitySystemStart EventType = "activity.system.start" + // EventTypeActivitySystemStop is emitted when MCPProxy server stops. + EventTypeActivitySystemStop EventType = "activity.system.stop" + // EventTypeActivityInternalToolCall is emitted when an internal tool (retrieve_tools, call_tool_*, etc.) completes. + EventTypeActivityInternalToolCall EventType = "activity.internal_tool_call.completed" + // EventTypeActivityConfigChange is emitted when configuration changes (server add/remove/update). + EventTypeActivityConfigChange EventType = "activity.config_change" ) // Event is a typed notification published by the runtime event bus. diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 1fbf469a..1c3eb01e 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -262,6 +262,16 @@ func (p *MCPProxyServer) emitActivityPolicyDecision(serverName, toolName, sessio } } +// emitActivityInternalToolCall safely emits an internal tool call completion event (Spec 024) +// internalToolName is the name of the internal tool (retrieve_tools, call_tool_read, etc.) +// targetServer and targetTool are used for call_tool_* handlers +// intent is the intent declaration metadata +func (p *MCPProxyServer) emitActivityInternalToolCall(internalToolName, targetServer, targetTool, toolVariant, sessionID, requestID, status, errorMsg string, durationMs int64, intent map[string]interface{}) { + if p.mainServer != nil && p.mainServer.runtime != nil { + p.mainServer.runtime.EmitActivityInternalToolCall(internalToolName, targetServer, targetTool, toolVariant, sessionID, requestID, status, errorMsg, durationMs, intent) + } +} + // 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 @@ -606,8 +616,18 @@ Common issues: // handleSearchServers implements the search_servers functionality func (p *MCPProxyServer) handleSearchServers(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + startTime := time.Now() + + // Extract session info for activity logging (Spec 024) + var sessionID string + if sess := mcpserver.ClientSessionFromContext(ctx); sess != nil { + sessionID = sess.SessionID() + } + requestID := fmt.Sprintf("%d-search_servers", time.Now().UnixNano()) + registry, err := request.RequireString("registry") if err != nil { + p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError(fmt.Sprintf("Missing required parameter 'registry': %v", err)), nil } @@ -630,6 +650,7 @@ func (p *MCPProxyServer) handleSearchServers(ctx context.Context, request mcp.Ca zap.String("search", search), zap.String("tag", tag), zap.Error(err)) + p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError(fmt.Sprintf("Search failed: %v", err)), nil } @@ -653,14 +674,27 @@ func (p *MCPProxyServer) handleSearchServers(ctx context.Context, request mcp.Ca jsonResult, err := json.Marshal(response) if err != nil { + p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError(fmt.Sprintf("Failed to serialize results: %v", err)), nil } + // Spec 024: Emit success event + p.emitActivityInternalToolCall("search_servers", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil) + return mcp.NewToolResultText(string(jsonResult)), nil } // handleListRegistries implements the list_registries functionality -func (p *MCPProxyServer) handleListRegistries(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (p *MCPProxyServer) handleListRegistries(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + startTime := time.Now() + + // Extract session info for activity logging (Spec 024) + var sessionID string + if sess := mcpserver.ClientSessionFromContext(ctx); sess != nil { + sessionID = sess.SessionID() + } + requestID := fmt.Sprintf("%d-list_registries", time.Now().UnixNano()) + registriesList := []map[string]interface{}{} allRegistries := registries.ListRegistries() for i := range allRegistries { @@ -681,16 +715,31 @@ func (p *MCPProxyServer) handleListRegistries(_ context.Context, _ mcp.CallToolR "message": "Available MCP registries. Use 'search_servers' tool with a registry ID to find servers.", }) if err != nil { + p.emitActivityInternalToolCall("list_registries", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError(fmt.Sprintf("Failed to serialize registries: %v", err)), nil } + // Spec 024: Emit success event + p.emitActivityInternalToolCall("list_registries", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil) + return mcp.NewToolResultText(string(jsonResult)), nil } // handleRetrieveTools implements the retrieve_tools functionality -func (p *MCPProxyServer) handleRetrieveTools(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (p *MCPProxyServer) handleRetrieveTools(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + startTime := time.Now() + + // Extract session info for activity logging (Spec 024) + var sessionID string + if sess := mcpserver.ClientSessionFromContext(ctx); sess != nil { + sessionID = sess.SessionID() + } + requestID := fmt.Sprintf("%d-retrieve_tools", time.Now().UnixNano()) + query, err := request.RequireString("query") if err != nil { + // Emit internal tool call event for error case + p.emitActivityInternalToolCall("retrieve_tools", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError(fmt.Sprintf("Missing required parameter 'query': %v", err)), nil } @@ -709,6 +758,7 @@ func (p *MCPProxyServer) handleRetrieveTools(_ context.Context, request mcp.Call results, err := p.index.Search(query, limit) if err != nil { p.logger.Error("Search failed", zap.String("query", query), zap.Error(err)) + p.emitActivityInternalToolCall("retrieve_tools", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError(fmt.Sprintf("Search failed: %v", err)), nil } @@ -809,9 +859,13 @@ func (p *MCPProxyServer) handleRetrieveTools(_ context.Context, request mcp.Call jsonResult, err := json.Marshal(response) if err != nil { + p.emitActivityInternalToolCall("retrieve_tools", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError(fmt.Sprintf("Failed to serialize results: %v", err)), nil } + // Emit success event (Spec 024) + p.emitActivityInternalToolCall("retrieve_tools", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil) + return mcp.NewToolResultText(string(jsonResult)), nil } @@ -832,6 +886,9 @@ func (p *MCPProxyServer) handleCallToolDestructive(ctx context.Context, request // handleCallToolVariant is the common handler for all call_tool_* variants (Spec 018) func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.CallToolRequest, toolVariant string) (*mcp.CallToolResult, error) { + // Spec 024: Track start time and context for internal tool call logging + internalStartTime := time.Now() + // Add panic recovery to ensure server resilience defer func() { if r := recover(); r != nil { @@ -1092,6 +1149,10 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp. } p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", err.Error(), duration.Milliseconds(), "", false, toolVariant, intentMap) + // Spec 024: Emit internal tool call event for error + internalToolName := "call_tool_" + intent.OperationType // e.g., "call_tool_read" + p.emitActivityInternalToolCall(internalToolName, serverName, actualToolName, toolVariant, sessionID, requestID, "error", err.Error(), time.Since(internalStartTime).Milliseconds(), intentMap) + return p.createDetailedErrorResponse(err, serverName, actualToolName), nil } @@ -1192,6 +1253,10 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp. } p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "success", "", duration.Milliseconds(), response, responseTruncated, toolVariant, intentMap) + // Spec 024: Emit internal tool call event for success + internalToolName := "call_tool_" + intent.OperationType // e.g., "call_tool_read" + p.emitActivityInternalToolCall(internalToolName, serverName, actualToolName, toolVariant, sessionID, requestID, "success", "", time.Since(internalStartTime).Milliseconds(), intentMap) + return mcp.NewToolResultText(response), nil } @@ -1626,19 +1691,31 @@ func (p *MCPProxyServer) handleQuarantinedToolCall(ctx context.Context, serverNa // handleUpstreamServers implements upstream server management func (p *MCPProxyServer) handleUpstreamServers(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + startTime := time.Now() + + // Extract session info for activity logging (Spec 024) + var sessionID string + if sess := mcpserver.ClientSessionFromContext(ctx); sess != nil { + sessionID = sess.SessionID() + } + requestID := fmt.Sprintf("%d-upstream_servers", time.Now().UnixNano()) + operation, err := request.RequireString("operation") if err != nil { + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError(fmt.Sprintf("Missing required parameter 'operation': %v", err)), nil } // Security checks if p.config.ReadOnlyMode { if operation != operationList { + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Operation not allowed in read-only mode", time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError("Operation not allowed in read-only mode"), nil } } if p.config.DisableManagement { + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Server management is disabled for security", time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError("Server management is disabled for security"), nil } @@ -1646,64 +1723,124 @@ func (p *MCPProxyServer) handleUpstreamServers(ctx context.Context, request mcp. switch operation { case operationAdd: if !p.config.AllowServerAdd { + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Adding servers is not allowed", time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError("Adding servers is not allowed"), nil } case operationRemove: if !p.config.AllowServerRemove { + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", "Removing servers is not allowed", time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError("Removing servers is not allowed"), nil } } + // Execute operation and track result + var result *mcp.CallToolResult + var opErr error + switch operation { case operationList: - return p.handleListUpstreams(ctx) + result, opErr = p.handleListUpstreams(ctx) case operationAdd: - return p.handleAddUpstream(ctx, request) + result, opErr = p.handleAddUpstream(ctx, request) case operationRemove: - return p.handleRemoveUpstream(ctx, request) + result, opErr = p.handleRemoveUpstream(ctx, request) case "update": - return p.handleUpdateUpstream(ctx, request) + result, opErr = p.handleUpdateUpstream(ctx, request) case "patch": - return p.handlePatchUpstream(ctx, request) + result, opErr = p.handlePatchUpstream(ctx, request) case "tail_log": - return p.handleTailLog(ctx, request) + result, opErr = p.handleTailLog(ctx, request) case "enable": - return p.handleEnableUpstream(ctx, request, true) + result, opErr = p.handleEnableUpstream(ctx, request, true) case "disable": - return p.handleEnableUpstream(ctx, request, false) + result, opErr = p.handleEnableUpstream(ctx, request, false) case "restart": - return p.handleRestartUpstream(ctx, request) + result, opErr = p.handleRestartUpstream(ctx, request) default: + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", fmt.Sprintf("Unknown operation: %s", operation), time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError(fmt.Sprintf("Unknown operation: %s", operation)), nil } + + // Spec 024: Emit activity event based on result + if opErr != nil { + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", opErr.Error(), time.Since(startTime).Milliseconds(), nil) + } else if result != nil && result.IsError { + // Extract error message from result if available + errMsg := "operation failed" + if len(result.Content) > 0 { + if textContent, ok := result.Content[0].(mcp.TextContent); ok { + errMsg = textContent.Text + } + } + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "error", errMsg, time.Since(startTime).Milliseconds(), nil) + } else { + p.emitActivityInternalToolCall("upstream_servers", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil) + } + + return result, opErr } // handleQuarantineSecurity implements the quarantine_security functionality func (p *MCPProxyServer) handleQuarantineSecurity(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + startTime := time.Now() + + // Extract session info for activity logging (Spec 024) + var sessionID string + if sess := mcpserver.ClientSessionFromContext(ctx); sess != nil { + sessionID = sess.SessionID() + } + requestID := fmt.Sprintf("%d-quarantine_security", time.Now().UnixNano()) + operation, err := request.RequireString("operation") if err != nil { + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError(fmt.Sprintf("Missing required parameter 'operation': %v", err)), nil } // Security checks if p.config.ReadOnlyMode { + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", "Quarantine operations not allowed in read-only mode", time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError("Quarantine operations not allowed in read-only mode"), nil } if p.config.DisableManagement { + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", "Server management is disabled for security", time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError("Server management is disabled for security"), nil } + // Execute operation and track result + var result *mcp.CallToolResult + var opErr error + switch operation { case "list_quarantined": - return p.handleListQuarantinedUpstreams(ctx) + result, opErr = p.handleListQuarantinedUpstreams(ctx) case "inspect_quarantined": - return p.handleInspectQuarantinedTools(ctx, request) + result, opErr = p.handleInspectQuarantinedTools(ctx, request) case "quarantine": - return p.handleQuarantineUpstream(ctx, request) + result, opErr = p.handleQuarantineUpstream(ctx, request) default: + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", fmt.Sprintf("Unknown quarantine operation: %s", operation), time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError(fmt.Sprintf("Unknown quarantine operation: %s", operation)), nil } + + // Spec 024: Emit activity event based on result + if opErr != nil { + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", opErr.Error(), time.Since(startTime).Milliseconds(), nil) + } else if result != nil && result.IsError { + // Extract error message from result if available + errMsg := "operation failed" + if len(result.Content) > 0 { + if textContent, ok := result.Content[0].(mcp.TextContent); ok { + errMsg = textContent.Text + } + } + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", errMsg, time.Since(startTime).Milliseconds(), nil) + } else { + p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil) + } + + return result, opErr } func (p *MCPProxyServer) handleListUpstreams(_ context.Context) (*mcp.CallToolResult, error) { @@ -2547,6 +2684,20 @@ func (p *MCPProxyServer) handleAddUpstream(ctx context.Context, request mcp.Call // Continue anyway - UpdateConfig above already notified the supervisor } p.mainServer.OnUpstreamServerChange() + + // Spec 024: Emit config change activity for server addition + newValues := map[string]interface{}{ + "protocol": protocol, + "enabled": enabled, + "quarantined": quarantined, + } + if url != "" { + newValues["url"] = url + } + if command != "" { + newValues["command"] = command + } + p.mainServer.runtime.EmitActivityConfigChange("server_added", name, "mcp", nil, nil, newValues) } // Wait briefly for supervisor to reconcile and connect (if enabled) @@ -2657,6 +2808,9 @@ func (p *MCPProxyServer) handleRemoveUpstream(_ context.Context, request mcp.Cal p.logger.Error("Failed to save configuration after removing server", zap.Error(err)) } p.mainServer.OnUpstreamServerChange() + + // Spec 024: Emit config change activity for server removal + p.mainServer.runtime.EmitActivityConfigChange("server_removed", name, "mcp", nil, nil, nil) } jsonResult, err := json.Marshal(map[string]interface{}{ @@ -3097,9 +3251,19 @@ func (p *MCPProxyServer) explainToolRanking(query, targetTool string, results [] } // handleReadCache implements the read_cache functionality -func (p *MCPProxyServer) handleReadCache(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (p *MCPProxyServer) handleReadCache(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + startTime := time.Now() + + // Extract session info for activity logging (Spec 024) + var sessionID string + if sess := mcpserver.ClientSessionFromContext(ctx); sess != nil { + sessionID = sess.SessionID() + } + requestID := fmt.Sprintf("%d-read_cache", time.Now().UnixNano()) + key, err := request.RequireString("key") if err != nil { + p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError(fmt.Sprintf("Missing required parameter 'key': %v", err)), nil } @@ -3109,24 +3273,31 @@ func (p *MCPProxyServer) handleReadCache(_ context.Context, request mcp.CallTool // Validate parameters if offset < 0 { + p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", "Offset must be non-negative", time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError("Offset must be non-negative"), nil } if limit <= 0 || limit > 1000 { + p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", "Limit must be between 1 and 1000", time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError("Limit must be between 1 and 1000"), nil } // Retrieve cached data response, err := p.cacheManager.GetRecords(key, offset, limit) if err != nil { + p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError(fmt.Sprintf("Failed to retrieve cached data: %v", err)), nil } // Serialize response jsonResult, err := json.Marshal(response) if err != nil { + p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "error", err.Error(), time.Since(startTime).Milliseconds(), nil) return mcp.NewToolResultError(fmt.Sprintf("Failed to serialize response: %v", err)), nil } + // Spec 024: Emit success event + p.emitActivityInternalToolCall("read_cache", "", "", "", sessionID, requestID, "success", "", time.Since(startTime).Milliseconds(), nil) + return mcp.NewToolResultText(string(jsonResult)), nil } diff --git a/internal/server/mcp_code_execution.go b/internal/server/mcp_code_execution.go index 52d69eb7..f1aa801c 100644 --- a/internal/server/mcp_code_execution.go +++ b/internal/server/mcp_code_execution.go @@ -296,6 +296,18 @@ func (p *MCPProxyServer) handleCodeExecution(ctx context.Context, request mcp.Ca return nil, fmt.Errorf("failed to serialize result: %w", err) } + // Spec 024: Emit internal tool call event for code_execution + var status, errorMsg string + if result.Ok { + status = "success" + } else { + status = "error" + if result.Error != nil { + errorMsg = result.Error.Message + } + } + p.emitActivityInternalToolCall("code_execution", "", "", "", sessionID, parentCallID, status, errorMsg, executionDuration.Milliseconds(), nil) + return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.NewTextContent(string(resultJSON)), diff --git a/internal/server/server.go b/internal/server/server.go index 55a49dae..11fd9bfe 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -61,6 +61,13 @@ type Server struct { statusCh chan interface{} eventsCh chan runtime.Event + + // Spec 024: Track server start time for lifecycle events + startTime time.Time + + // Spec 024: Shutdown info for lifecycle events + shutdownReason string + shutdownSignal string } // NewServer creates a new server instance @@ -221,6 +228,11 @@ func (s *Server) forwardRuntimeStatus() { // Start starts the MCP proxy server func (s *Server) Start(ctx context.Context) error { + // Spec 024: Track server start time for lifecycle events + s.mu.Lock() + s.startTime = time.Now() + s.mu.Unlock() + s.logger.Info("Starting MCP proxy server") // Handle graceful shutdown when context is cancelled (for full application shutdown only) @@ -286,6 +298,19 @@ func (s *Server) Start(ctx context.Context) error { s.updateStatus(runtime.PhaseRunning, "Server is running in stdio mode") s.runtime.SetRunning(true) + // Spec 024: Emit system_start activity event for stdio mode + startupDurationMs := time.Since(s.startTime).Milliseconds() + configPath := "" + if s.runtime != nil { + configPath = s.runtime.ConfigPath() + } + s.runtime.EmitActivitySystemStart( + httpapi.GetBuildVersion(), + "stdio", + startupDurationMs, + configPath, + ) + // Serve using stdio (standard MCP transport) if err := server.ServeStdio(s.mcpProxy.GetMCPServer()); err != nil { return fmt.Errorf("MCP server error: %w", err) @@ -296,6 +321,16 @@ func (s *Server) Start(ctx context.Context) error { } // discoverAndIndexTools discovers tools from upstream servers and indexes them + +// SetShutdownInfo sets the reason and signal for shutdown (Spec 024). +// Call this before Shutdown() to include shutdown context in activity logs. +func (s *Server) SetShutdownInfo(reason, signal string) { + s.mu.Lock() + defer s.mu.Unlock() + s.shutdownReason = reason + s.shutdownSignal = signal +} + // Shutdown gracefully shuts down the server func (s *Server) Shutdown() error { s.mu.Lock() @@ -306,8 +341,20 @@ func (s *Server) Shutdown() error { } s.shutdown = true httpServer := s.httpServer + startTime := s.startTime + reason := s.shutdownReason + signal := s.shutdownSignal s.mu.Unlock() + // Spec 024: Emit system_stop event before actual shutdown begins + if s.runtime != nil && !startTime.IsZero() { + uptimeSeconds := int64(time.Since(startTime).Seconds()) + if reason == "" { + reason = "shutdown" + } + s.runtime.EmitActivitySystemStop(reason, signal, uptimeSeconds, "") + } + if s.eventsCh != nil { s.runtime.UnsubscribeEvents(s.eventsCh) } @@ -1360,6 +1407,19 @@ func (s *Server) startCustomHTTPServer(ctx context.Context, streamableServer *se // Broadcast running status with resolved listen address so readiness checks succeed immediately. s.updateStatus(runtime.PhaseRunning, fmt.Sprintf("Server is running on %s", displayAddr)) + // Spec 024: Emit system_start activity event + startupDurationMs := time.Since(s.startTime).Milliseconds() + configPath := "" + if s.runtime != nil { + configPath = s.runtime.ConfigPath() + } + s.runtime.EmitActivitySystemStart( + httpapi.GetBuildVersion(), + displayAddr, + startupDurationMs, + configPath, + ) + // List all registered endpoints for visibility allEndpoints := []string{ "/mcp", "/mcp/", // MCP protocol endpoints diff --git a/internal/storage/activity_models.go b/internal/storage/activity_models.go index 323a3153..b6368b34 100644 --- a/internal/storage/activity_models.go +++ b/internal/storage/activity_models.go @@ -20,8 +20,28 @@ const ( ActivityTypeQuarantineChange ActivityType = "quarantine_change" // ActivityTypeServerChange represents a server configuration change ActivityTypeServerChange ActivityType = "server_change" + // ActivityTypeSystemStart represents MCPProxy server startup (Spec 024) + ActivityTypeSystemStart ActivityType = "system_start" + // ActivityTypeSystemStop represents MCPProxy server shutdown (Spec 024) + ActivityTypeSystemStop ActivityType = "system_stop" + // ActivityTypeInternalToolCall represents internal MCP tool calls like retrieve_tools, call_tool_* (Spec 024) + ActivityTypeInternalToolCall ActivityType = "internal_tool_call" + // ActivityTypeConfigChange represents configuration changes like server add/remove/update (Spec 024) + ActivityTypeConfigChange ActivityType = "config_change" ) +// ValidActivityTypes is the list of all valid activity types for filtering (Spec 024) +var ValidActivityTypes = []string{ + string(ActivityTypeToolCall), + string(ActivityTypePolicyDecision), + string(ActivityTypeQuarantineChange), + string(ActivityTypeServerChange), + string(ActivityTypeSystemStart), + string(ActivityTypeSystemStop), + string(ActivityTypeInternalToolCall), + string(ActivityTypeConfigChange), +} + // ActivitySource indicates how the activity was triggered type ActivitySource string @@ -65,7 +85,7 @@ func (a *ActivityRecord) UnmarshalBinary(data []byte) error { // ActivityFilter represents query parameters for filtering activity records type ActivityFilter struct { - Type string // Filter by activity type + Types []string // Filter by activity types (Spec 024: supports multiple types with OR logic) Server string // Filter by server name Tool string // Filter by tool name SessionID string // Filter by MCP session @@ -101,9 +121,18 @@ func (f *ActivityFilter) Validate() { // Matches checks if an activity record matches the filter criteria func (f *ActivityFilter) Matches(record *ActivityRecord) bool { - // Check type filter - if f.Type != "" && string(record.Type) != f.Type { - return false + // Check types filter (Spec 024: OR logic for multiple types) + if len(f.Types) > 0 { + typeMatches := false + for _, t := range f.Types { + if string(record.Type) == t { + typeMatches = true + break + } + } + if !typeMatches { + return false + } } // Check server filter diff --git a/internal/storage/activity_test.go b/internal/storage/activity_test.go index e87ae1a0..7840821c 100644 --- a/internal/storage/activity_test.go +++ b/internal/storage/activity_test.go @@ -140,13 +140,28 @@ func TestActivityFilter_Matches(t *testing.T) { matches: true, }, { - name: "type matches", - filter: ActivityFilter{Type: "tool_call"}, + name: "single type matches", + filter: ActivityFilter{Types: []string{"tool_call"}}, matches: true, }, { - name: "type does not match", - filter: ActivityFilter{Type: "policy_decision"}, + name: "single type does not match", + filter: ActivityFilter{Types: []string{"policy_decision"}}, + matches: false, + }, + { + name: "multiple types OR logic - first matches", + filter: ActivityFilter{Types: []string{"tool_call", "policy_decision"}}, + matches: true, + }, + { + name: "multiple types OR logic - second matches", + filter: ActivityFilter{Types: []string{"policy_decision", "tool_call"}}, + matches: true, + }, + { + name: "multiple types OR logic - none match", + filter: ActivityFilter{Types: []string{"policy_decision", "quarantine_change"}}, matches: false, }, { @@ -204,7 +219,7 @@ func TestActivityFilter_Matches(t *testing.T) { { name: "multiple filters all match", filter: ActivityFilter{ - Type: "tool_call", + Types: []string{"tool_call"}, Server: "github", Status: "success", }, @@ -213,7 +228,7 @@ func TestActivityFilter_Matches(t *testing.T) { { name: "multiple filters one fails", filter: ActivityFilter{ - Type: "tool_call", + Types: []string{"tool_call"}, Server: "gitlab", // does not match Status: "success", }, @@ -401,8 +416,8 @@ func TestListActivities_Filtering(t *testing.T) { require.NoError(t, err) } - // Filter by type - filter := ActivityFilter{Type: "tool_call", Limit: 50} + // Filter by single type + filter := ActivityFilter{Types: []string{"tool_call"}, Limit: 50} records, total, err := manager.ListActivities(filter) require.NoError(t, err) assert.Equal(t, 3, total) @@ -420,11 +435,17 @@ func TestListActivities_Filtering(t *testing.T) { require.NoError(t, err) assert.Equal(t, 2, total) - // Combined filters - filter = ActivityFilter{Type: "tool_call", Server: "github", Limit: 50} + // Combined filters with single type + filter = ActivityFilter{Types: []string{"tool_call"}, Server: "github", Limit: 50} _, total, err = manager.ListActivities(filter) require.NoError(t, err) assert.Equal(t, 2, total) + + // Multi-type filter (Spec 024) + filter = ActivityFilter{Types: []string{"tool_call", "policy_decision"}, Limit: 50} + _, total, err = manager.ListActivities(filter) + require.NoError(t, err) + assert.Equal(t, 4, total) // 3 tool_call + 1 policy_decision } func TestDeleteActivity(t *testing.T) { diff --git a/oas/docs.go b/oas/docs.go index be7ce401..3f1e4d8b 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":{"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"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_response_limit":{"type":"integer"},"tools_limit":{"type":"integer"},"top_k":{"type":"integer"},"tray_endpoint":{"description":"Tray endpoint override (unix:// or npipe://)","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":"Feature flags for modular functionality","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.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"},"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"},"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"},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"id":{"description":"Unique identifier (ULID format)","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.Diagnostics":{"properties":{"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.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"},"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"},"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","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_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":"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/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/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":"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/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/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 d30032b7..4f5ae037 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -1549,7 +1549,8 @@ paths: get: description: Returns paginated list of activity records with optional filtering parameters: - - description: Filter by activity type + - description: Filter by activity type(s), comma-separated for multiple (Spec + 024) in: query name: type schema: @@ -1558,6 +1559,10 @@ paths: - policy_decision - quarantine_change - server_change + - system_start + - system_stop + - internal_tool_call + - config_change type: string - description: Filter by server name in: query diff --git a/specs/024-expand-activity-log/checklists/requirements.md b/specs/024-expand-activity-log/checklists/requirements.md new file mode 100644 index 00000000..a17cffe7 --- /dev/null +++ b/specs/024-expand-activity-log/checklists/requirements.md @@ -0,0 +1,45 @@ +# Specification Quality Checklist: Expand Activity Log + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-12 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification +- [x] Testing approach defined (Web UI via Claude extension, CLI via shell) +- [x] Documentation requirements included (Docusaurus updates) + +## Notes + +- All items pass validation +- Spec is ready for `/speckit.clarify` or `/speckit.plan` +- The existing JsonViewer component already supports syntax highlighting based on review of current Activity.vue implementation +- Activity Log menu item already exists but is commented out in SidebarNav.vue - spec documents enabling it +- CLI already supports `--type` flag but only for single type - spec extends to comma-separated multiple types +- Documentation updates required in `docs/` Docusaurus site +- Testing approach includes: + - Web UI testing via Claude browser automation (mcp__claude-in-chrome__* or mcp__playwriter__*) + - CLI testing via Bash tool + - Backend testing via API calls diff --git a/specs/024-expand-activity-log/contracts/activity-api-changes.yaml b/specs/024-expand-activity-log/contracts/activity-api-changes.yaml new file mode 100644 index 00000000..f4d656e6 --- /dev/null +++ b/specs/024-expand-activity-log/contracts/activity-api-changes.yaml @@ -0,0 +1,312 @@ +# Activity API Changes for Spec 024 +# These changes extend the existing activity API endpoints + +openapi: 3.0.0 +info: + title: Activity API Extensions (Spec 024) + version: 1.0.0 + description: | + Extensions to the Activity Log API for Spec 024 (Expand Activity Log). + + Changes: + - Multi-type filter support (comma-separated types) + - New event types: system_start, system_stop, internal_tool_call, config_change + +paths: + /api/v1/activity: + get: + summary: List activity records + description: | + List activity records with filtering and pagination. + + **New in Spec 024**: The `type` parameter now accepts comma-separated values + for filtering by multiple event types (OR logic). + parameters: + - name: type + in: query + description: | + Filter by activity type(s). Accepts comma-separated values for multi-type filtering. + + Valid types: + - `tool_call` - Upstream tool executions + - `policy_decision` - Policy blocking tool calls + - `quarantine_change` - Server quarantine changes + - `server_change` - Server enable/disable/restart + - `system_start` - MCPProxy server started (NEW) + - `system_stop` - MCPProxy server stopped (NEW) + - `internal_tool_call` - Internal MCP tool calls (NEW) + - `config_change` - Configuration changes (NEW) + + Example: `?type=tool_call,internal_tool_call` + schema: + type: string + example: "tool_call,internal_tool_call" + - name: server + in: query + description: Filter by server name + schema: + type: string + - name: status + in: query + description: Filter by status (success, error, blocked) + schema: + type: string + enum: [success, error, blocked] + - name: intent_type + in: query + description: Filter by intent operation type + schema: + type: string + enum: [read, write, destructive] + - name: request_id + in: query + description: Filter by HTTP request ID + schema: + type: string + - name: limit + in: query + description: Maximum records to return (1-100, default 50) + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + - name: offset + in: query + description: Pagination offset + schema: + type: integer + default: 0 + responses: + '200': + description: Activity records + content: + application/json: + schema: + $ref: '#/components/schemas/ActivityListResponse' + +components: + schemas: + ActivityType: + type: string + description: Type of activity event + enum: + - tool_call + - policy_decision + - quarantine_change + - server_change + - system_start # NEW + - system_stop # NEW + - internal_tool_call # NEW + - config_change # NEW + + ActivityRecord: + type: object + required: + - id + - type + - status + - timestamp + properties: + id: + type: string + description: Unique identifier (ULID format) + example: "01JFXYZ123ABC" + type: + $ref: '#/components/schemas/ActivityType' + source: + type: string + description: How activity was triggered + enum: [mcp, cli, api] + server_name: + type: string + description: Name of upstream MCP server (if applicable) + tool_name: + type: string + description: Name of tool called (if applicable) + arguments: + type: object + description: Tool call arguments + additionalProperties: true + response: + type: string + description: Tool response (potentially truncated) + response_truncated: + type: boolean + description: True if response was truncated + status: + type: string + description: Result status + enum: [success, error, blocked] + error_message: + type: string + description: Error details if status is error + duration_ms: + type: integer + description: Execution duration in milliseconds + timestamp: + type: string + format: date-time + description: When activity occurred + session_id: + type: string + description: MCP session ID for correlation + request_id: + type: string + description: HTTP request ID for correlation + metadata: + type: object + description: Type-specific metadata + additionalProperties: true + + # NEW: System Start Metadata + SystemStartMetadata: + type: object + description: Metadata for system_start events + properties: + version: + type: string + description: MCPProxy version + example: "v1.2.3" + listen_address: + type: string + description: HTTP listener address + example: "127.0.0.1:8080" + startup_duration_ms: + type: integer + description: Time from command start to ready + example: 1250 + config_path: + type: string + description: Path to config file used + example: "/Users/user/.mcpproxy/mcp_config.json" + unclean_previous_shutdown: + type: boolean + description: True if previous session didn't log system_stop + + # NEW: System Stop Metadata + SystemStopMetadata: + type: object + description: Metadata for system_stop events + properties: + reason: + type: string + description: Shutdown reason + enum: [signal, manual, error] + signal: + type: string + description: Signal name if reason is signal + example: "SIGTERM" + uptime_seconds: + type: integer + description: Total server uptime + example: 3600 + error_message: + type: string + description: Error details if reason is error + + # NEW: Internal Tool Call Metadata + InternalToolCallMetadata: + type: object + description: Metadata for internal_tool_call events + properties: + internal_tool_name: + type: string + description: Name of internal tool called + enum: + - retrieve_tools + - call_tool_read + - call_tool_write + - call_tool_destructive + - code_execution + - upstream_servers + - quarantine_security + - list_registries + - search_servers + - read_cache + target_server: + type: string + description: Target upstream server (for call_tool_*) + target_tool: + type: string + description: Target upstream tool (for call_tool_*) + tool_variant: + type: string + description: Tool variant used + enum: [call_tool_read, call_tool_write, call_tool_destructive] + intent: + $ref: '#/components/schemas/IntentDeclaration' + + # NEW: Config Change Metadata + ConfigChangeMetadata: + type: object + description: Metadata for config_change events + properties: + action: + type: string + description: Type of config change + enum: + - server_added + - server_removed + - server_updated + - settings_changed + affected_entity: + type: string + description: Name of affected server or "global" + changed_fields: + type: array + items: + type: string + description: List of fields that changed + previous_values: + type: object + description: Previous values for changed fields + additionalProperties: true + new_values: + type: object + description: New values for changed fields + additionalProperties: true + + IntentDeclaration: + type: object + description: Intent declaration from Spec 018 + properties: + operation_type: + type: string + enum: [read, write, destructive] + data_sensitivity: + type: string + enum: [public, internal, private, unknown] + reason: + type: string + description: Agent's explanation for the operation + + ActivityListResponse: + type: object + required: + - success + - data + properties: + success: + type: boolean + data: + type: object + required: + - activities + - total + - limit + - offset + properties: + activities: + type: array + items: + $ref: '#/components/schemas/ActivityRecord' + total: + type: integer + description: Total matching records + limit: + type: integer + description: Records per page + offset: + type: integer + description: Current offset diff --git a/specs/024-expand-activity-log/data-model.md b/specs/024-expand-activity-log/data-model.md new file mode 100644 index 00000000..607edf2c --- /dev/null +++ b/specs/024-expand-activity-log/data-model.md @@ -0,0 +1,238 @@ +# Data Model: Expand Activity Log + +**Date**: 2026-01-12 +**Feature**: 024-expand-activity-log + +## Entity Changes + +### 1. ActivityType Enumeration (Extended) + +**Location**: `internal/storage/activity_models.go` + +| Constant | Value | Description | +|----------|-------|-------------| +| `ActivityTypeToolCall` | `"tool_call"` | Upstream tool execution (existing) | +| `ActivityTypePolicyDecision` | `"policy_decision"` | Policy blocking a tool call (existing) | +| `ActivityTypeQuarantineChange` | `"quarantine_change"` | Server quarantine state change (existing) | +| `ActivityTypeServerChange` | `"server_change"` | Server enable/disable/restart (existing) | +| `ActivityTypeSystemStart` | `"system_start"` | **NEW**: MCPProxy server started | +| `ActivityTypeSystemStop` | `"system_stop"` | **NEW**: MCPProxy server stopped | +| `ActivityTypeInternalToolCall` | `"internal_tool_call"` | **NEW**: Internal MCP tool call | +| `ActivityTypeConfigChange` | `"config_change"` | **NEW**: Configuration change | + +### 2. ActivityRecord (Extended Metadata) + +**Location**: `internal/storage/activity_models.go` + +The `ActivityRecord` struct remains unchanged. New event types use the existing `Metadata` field to store type-specific data. + +```go +type ActivityRecord struct { + ID string `json:"id"` + Type ActivityType `json:"type"` + Source ActivitySource `json:"source,omitempty"` + ServerName string `json:"server_name,omitempty"` + ToolName string `json:"tool_name,omitempty"` + Arguments map[string]interface{} `json:"arguments,omitempty"` + Response string `json:"response,omitempty"` + ResponseTruncated bool `json:"response_truncated,omitempty"` + Status string `json:"status"` + ErrorMessage string `json:"error_message,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` + Timestamp time.Time `json:"timestamp"` + SessionID string `json:"session_id,omitempty"` + RequestID string `json:"request_id,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} +``` + +### 3. Type-Specific Metadata Schemas + +#### 3.1 SystemStartMetadata + +For `system_start` events: + +```json +{ + "version": "v1.2.3", + "listen_address": "127.0.0.1:8080", + "startup_duration_ms": 1250, + "config_path": "/Users/user/.mcpproxy/mcp_config.json", + "unclean_previous_shutdown": false +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `version` | string | MCPProxy version | +| `listen_address` | string | HTTP listener address | +| `startup_duration_ms` | int64 | Time from command start to ready | +| `config_path` | string | Path to config file used | +| `unclean_previous_shutdown` | bool | True if previous session didn't log system_stop | + +#### 3.2 SystemStopMetadata + +For `system_stop` events: + +```json +{ + "reason": "signal", + "signal": "SIGTERM", + "uptime_seconds": 3600, + "error_message": "" +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `reason` | string | Shutdown reason: `"signal"`, `"manual"`, `"error"` | +| `signal` | string | Signal name if reason is signal | +| `uptime_seconds` | int64 | Total server uptime | +| `error_message` | string | Error details if reason is error | + +#### 3.3 InternalToolCallMetadata + +For `internal_tool_call` events: + +```json +{ + "internal_tool_name": "retrieve_tools", + "target_server": "github", + "target_tool": "create_issue", + "tool_variant": "call_tool_write", + "intent": { + "operation_type": "write", + "data_sensitivity": "internal", + "reason": "Creating issue per user request" + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `internal_tool_name` | string | Name of internal tool called | +| `target_server` | string | Target upstream server (for call_tool_*) | +| `target_tool` | string | Target upstream tool (for call_tool_*) | +| `tool_variant` | string | Tool variant used (call_tool_read/write/destructive) | +| `intent` | object | Intent declaration (from Spec 018) | + +#### 3.4 ConfigChangeMetadata + +For `config_change` events: + +```json +{ + "action": "server_added", + "affected_entity": "github-server", + "changed_fields": ["enabled", "quarantined"], + "previous_values": {"enabled": false}, + "new_values": {"enabled": true} +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `action` | string | `"server_added"`, `"server_removed"`, `"server_updated"`, `"settings_changed"` | +| `affected_entity` | string | Name of affected server or "global" | +| `changed_fields` | []string | List of fields that changed | +| `previous_values` | object | Previous values for changed fields | +| `new_values` | object | New values for changed fields | + +### 4. ActivityFilter (Extended) + +**Location**: `internal/storage/activity_models.go` + +```go +type ActivityFilter struct { + Types []string // Changed from single Type string - supports multi-type OR filter + Server string + Tool string + SessionID string + Status string + StartTime time.Time + EndTime time.Time + Limit int + Offset int + IntentType string + RequestID string +} +``` + +**Changes**: +- `Type string` → `Types []string` +- `Matches()` method updated for OR logic on types + +### 5. EventType Constants (Extended) + +**Location**: `internal/runtime/events.go` + +| Constant | Value | Description | +|----------|-------|-------------| +| `EventTypeActivitySystemStart` | `"activity.system.start"` | **NEW**: System started | +| `EventTypeActivitySystemStop` | `"activity.system.stop"` | **NEW**: System stopped | +| `EventTypeActivityInternalToolCall` | `"activity.internal_tool_call.completed"` | **NEW**: Internal tool call completed | +| `EventTypeActivityConfigChange` | `"activity.config_change"` | **NEW**: Config changed | + +## Validation Rules + +### ActivityType Validation + +Valid activity types for filtering: + +```go +var ValidActivityTypes = []string{ + "tool_call", + "policy_decision", + "quarantine_change", + "server_change", + "system_start", + "system_stop", + "internal_tool_call", + "config_change", +} +``` + +### ConfigChange Action Validation + +Valid config change actions: + +```go +var ValidConfigActions = []string{ + "server_added", + "server_removed", + "server_updated", + "settings_changed", +} +``` + +## State Transitions + +### Server Lifecycle + +``` +[Not Running] → system_start → [Running] → system_stop → [Not Running] +``` + +### Config Change Flow + +``` +[User Action] → [API/CLI Handler] → [ServerManager] → [Storage] + ↓ + [EventBus: servers.changed] + ↓ + [ActivityService] → config_change record +``` + +## Storage Considerations + +- **No schema migration required**: New activity types use existing BBolt bucket +- **Metadata flexibility**: Type-specific data stored in `Metadata` map +- **Retention unchanged**: Existing retention policy applies to all activity types +- **Index unchanged**: No new BBolt indexes needed (filter by type uses iteration) + +## Backwards Compatibility + +- Existing `Type` filter API parameter continues to work (treated as single-element array) +- Existing activity records remain valid +- Clients that don't understand new types will see them as unknown strings +- SSE events for new types will be ignored by old clients diff --git a/specs/024-expand-activity-log/plan.md b/specs/024-expand-activity-log/plan.md new file mode 100644 index 00000000..0c9b7520 --- /dev/null +++ b/specs/024-expand-activity-log/plan.md @@ -0,0 +1,147 @@ +# Implementation Plan: Expand Activity Log + +**Branch**: `024-expand-activity-log` | **Date**: 2026-01-12 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/024-expand-activity-log/spec.md` + +## Summary + +Expand the Activity Log to capture system lifecycle events (start/stop), internal tool calls (retrieve_tools, call_tool_*, code_execution), and configuration changes. Enhance the Web UI with multi-select event type filtering, an Intent column, and sortable columns. Extend CLI to support comma-separated event type filtering. Update Docusaurus documentation. + +## Technical Context + +**Language/Version**: Go 1.24 (toolchain go1.24.10) + TypeScript 5.x / Vue 3.5 +**Primary Dependencies**: Cobra CLI, Chi router, BBolt storage, Zap logging, mark3labs/mcp-go, Vue 3, Tailwind CSS, DaisyUI +**Storage**: BBolt database (`~/.mcpproxy/config.db`) - ActivityRecord model +**Testing**: `go test`, E2E via `./scripts/test-api-e2e.sh`, Claude browser automation for Web UI +**Target Platform**: macOS, Linux, Windows desktop +**Project Type**: Web application (Go backend + Vue frontend) +**Performance Goals**: Activity records queryable within 100ms for up to 10,000 records; SSE events delivered within 50ms +**Constraints**: Non-blocking activity recording; existing retention policy (7 days, 10,000 records) +**Scale/Scope**: Up to 1,000 tools across multiple upstream servers; Activity Log handles ~10,000 records + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Performance at Scale | ✅ PASS | Activity recording is non-blocking via event-driven architecture. No changes to tool indexing/search. | +| II. Actor-Based Concurrency | ✅ PASS | ActivityService uses goroutine + channel pattern. New event types use same pattern. | +| III. Configuration-Driven Architecture | ✅ PASS | No new config settings required. Uses existing activity retention config. | +| IV. Security by Default | ✅ PASS | Activity logging enhances auditability. No security regressions. | +| V. Test-Driven Development (TDD) | ✅ PASS | Tests required for new event types, API changes, and Web UI features. | +| VI. Documentation Hygiene | ✅ PASS | Documentation updates explicitly required in spec (FR-027 to FR-030). | + +**Architecture Constraints:** + +| Constraint | Status | Notes | +|------------|--------|-------| +| Core + Tray Split | ✅ PASS | Changes are in core only. Tray receives updates via SSE. | +| Event-Driven Updates | ✅ PASS | New event types use existing EventBus pattern. | +| DDD Layering | ✅ PASS | Storage models in `internal/storage/`, service in `internal/runtime/`, API in `internal/httpapi/`. | +| Upstream Client Modularity | ✅ N/A | No changes to upstream client layers. | + +## Project Structure + +### Documentation (this feature) + +```text +specs/024-expand-activity-log/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +└── tasks.md # Phase 2 output (via /speckit.tasks) +``` + +### Source Code (repository root) + +```text +# Backend (Go) +internal/ +├── storage/ +│ └── activity_models.go # Extended ActivityType enum +├── runtime/ +│ ├── activity_service.go # Extended handlers for new event types +│ └── events.go # New EventType constants +├── httpapi/ +│ └── activity.go # Multi-type filter support in API +└── server/ + └── mcp_handler.go # Emit internal_tool_call events + +cmd/mcpproxy/ +├── activity_cmd.go # Extended --type flag validation +└── serve_cmd.go # Emit system_start/system_stop events + +# Frontend (Vue) +frontend/src/ +├── views/ +│ └── Activity.vue # Multi-select filter, Intent column, sortable columns +├── components/ +│ ├── SidebarNav.vue # Enable Activity Log menu item +│ └── JsonViewer.vue # Already exists with syntax highlighting +└── types/ + └── api.ts # Extended ActivityType enum + +# Documentation (Docusaurus) +docs/ +├── features/ +│ └── activity-log.md # Document new event types +└── cli/ + └── activity-commands.md # Document multi-type filtering + +# Tests +internal/ +├── storage/activity_test.go # Tests for new activity types +├── runtime/activity_service_test.go # Tests for event handlers +└── httpapi/activity_test.go # Tests for multi-type filter API +``` + +**Structure Decision**: Existing web application structure with Go backend and Vue frontend. Changes extend existing files rather than creating new ones. + +## Complexity Tracking + +No constitution violations requiring justification. The implementation extends existing patterns: + +- New ActivityType constants extend existing enum +- New event handlers follow existing ActivityService pattern +- Multi-type filtering extends existing filter logic +- Web UI changes extend existing Activity.vue component + +## Constitution Check (Post-Design) + +*Re-evaluation after Phase 1 design artifacts completed.* + +| Principle | Status | Post-Design Notes | +|-----------|--------|-------------------| +| I. Performance at Scale | ✅ PASS | Multi-type filter uses OR logic on iteration; pagination limits to 100 records so performance impact minimal. Client-side sorting handles max 100 records. | +| II. Actor-Based Concurrency | ✅ PASS | All new event emissions go through existing EventBus channel. ActivityService handles events in single goroutine. | +| III. Configuration-Driven Architecture | ✅ PASS | No new config required. All new features use existing infrastructure. | +| IV. Security by Default | ✅ PASS | Config change logging enhances audit trail. Internal tool call logging provides visibility into agent behavior. | +| V. Test-Driven Development (TDD) | ✅ PASS | quickstart.md defines test plan. Unit tests for new types, E2E for API, manual UI testing via browser automation. | +| VI. Documentation Hygiene | ✅ PASS | Documentation files identified in quickstart.md. Docusaurus updates planned for features and CLI sections. | + +**Architecture Constraints (Post-Design):** + +| Constraint | Status | Post-Design Notes | +|------------|--------|-------------------| +| Core + Tray Split | ✅ PASS | All changes in core server. SSE delivers events to tray/web UI. No tray code changes needed. | +| Event-Driven Updates | ✅ PASS | Four new EventType constants. ActivityService extended to handle config_change events. | +| DDD Layering | ✅ PASS | data-model.md confirms: storage models, runtime service, httpapi handler, server emission points. | +| Upstream Client Modularity | ✅ N/A | No changes to upstream client layers. | + +## Phase 1 Artifacts + +| Artifact | Path | Status | +|----------|------|--------| +| Research | [research.md](./research.md) | Complete | +| Data Model | [data-model.md](./data-model.md) | Complete | +| API Contracts | [contracts/activity-api-changes.yaml](./contracts/activity-api-changes.yaml) | Complete | +| Quickstart | [quickstart.md](./quickstart.md) | Complete | + +## Next Steps + +1. Run `/speckit.tasks` to generate tasks.md +2. Implement in order defined in quickstart.md +3. Test using Claude browser automation for Web UI and Bash for CLI diff --git a/specs/024-expand-activity-log/quickstart.md b/specs/024-expand-activity-log/quickstart.md new file mode 100644 index 00000000..3f192a7f --- /dev/null +++ b/specs/024-expand-activity-log/quickstart.md @@ -0,0 +1,259 @@ +# Quickstart: Expand Activity Log (Spec 024) + +This guide provides the implementation order and key files for each component. + +## Implementation Order + +1. **Backend: New Event Types** (P1) +2. **Backend: Multi-Type Filter** (P2) +3. **Web UI: Enable Activity Log Menu** (P2) +4. **Web UI: Multi-Select Filter** (P2) +5. **Web UI: Intent Column & Sorting** (P2) +6. **CLI: Multi-Type Filter** (P3) +7. **Documentation** (P3) +8. **Testing & Validation** + +--- + +## 1. Backend: New Event Types + +### 1.1 Add ActivityType Constants + +**File**: `internal/storage/activity_models.go` + +```go +const ( + // Existing types... + ActivityTypeToolCall ActivityType = "tool_call" + ActivityTypePolicyDecision ActivityType = "policy_decision" + ActivityTypeQuarantineChange ActivityType = "quarantine_change" + ActivityTypeServerChange ActivityType = "server_change" + + // NEW: Spec 024 + ActivityTypeSystemStart ActivityType = "system_start" + ActivityTypeSystemStop ActivityType = "system_stop" + ActivityTypeInternalToolCall ActivityType = "internal_tool_call" + ActivityTypeConfigChange ActivityType = "config_change" +) +``` + +### 1.2 Add EventType Constants + +**File**: `internal/runtime/events.go` + +```go +const ( + // Existing... + + // NEW: Spec 024 + EventTypeActivitySystemStart EventType = "activity.system.start" + EventTypeActivitySystemStop EventType = "activity.system.stop" + EventTypeActivityInternalToolCall EventType = "activity.internal_tool_call.completed" + EventTypeActivityConfigChange EventType = "activity.config_change" +) +``` + +### 1.3 Emit System Start/Stop Events + +**File**: `internal/server/server.go` + +In `StartServer()` method after HTTP listener bind: +```go +s.runtime.EmitActivitySystemStart(version, listenAddr, startupDurationMs, configPath) +``` + +In `Shutdown()` method at the beginning: +```go +s.runtime.EmitActivitySystemStop(reason, uptimeSeconds, errorMsg) +``` + +### 1.4 Emit Internal Tool Call Events + +**File**: `internal/server/mcp.go` + +In each internal tool handler (`handleRetrieveTools`, `handleCallToolRead`, etc.): +```go +p.emitActivityInternalToolCall(toolName, targetServer, targetTool, args, status, durationMs, intent) +``` + +### 1.5 Emit Config Change Events + +**File**: `internal/runtime/activity_service.go` + +Subscribe to `EventTypeServersChanged` and create `config_change` activity records. + +--- + +## 2. Backend: Multi-Type Filter + +### 2.1 Update ActivityFilter + +**File**: `internal/storage/activity_models.go` + +```go +type ActivityFilter struct { + Types []string // Changed from Type string + Server string + // ... rest unchanged +} +``` + +Update `Matches()` method for OR logic. + +### 2.2 Update API Handler + +**File**: `internal/httpapi/activity.go` + +Parse comma-separated `type` parameter: +```go +typeParam := r.URL.Query().Get("type") +if typeParam != "" { + filter.Types = strings.Split(typeParam, ",") +} +``` + +--- + +## 3. Web UI: Enable Activity Log Menu + +**File**: `frontend/src/components/SidebarNav.vue` + +Uncomment or add Activity Log menu item: +```vue + + Activity Log + +``` + +--- + +## 4. Web UI: Multi-Select Filter + +**File**: `frontend/src/views/Activity.vue` + +Create multi-select dropdown component: +```vue + +``` + +--- + +## 5. Web UI: Intent Column & Sorting + +**File**: `frontend/src/views/Activity.vue` + +### Intent Column + +Add column to table: +```vue +
Intent + + {{ getIntentIcon(activity.metadata.intent.operation_type) }} + {{ truncate(activity.metadata.intent.reason, 30) }} + + - + + Time {{ getSortIndicator('timestamp') }} +