diff --git a/.gitignore b/.gitignore index c06cbcd5a..2b559c5f7 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,6 @@ scripts/demo/node_modules # Transient work artifacts (brainstorm logs, editor backups) *.bak **/execution_log.md + +# Subagent-driven-development scratch workspace (ledger, briefs, review packages) +.superpowers/ diff --git a/docs/api/rest-api.md b/docs/api/rest-api.md index aa3ee7b5f..9ba753fc4 100644 --- a/docs/api/rest-api.md +++ b/docs/api/rest-api.md @@ -927,7 +927,23 @@ Get secret metadata (not the value). #### GET /api/v1/sessions -List active MCP sessions. +List recent MCP sessions. + +**Query Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `limit` | integer | Max sessions (1-100, default: 10) | +| `offset` | integer | Pagination offset (default: 0) | +| `status` | string | Filter by session status: `active`, `closed`. Any other value returns `400`. | + +The `status` filter is applied during the storage walk, **before** the `limit` +truncation, so a long-running session that is still active is returned even when +newer sessions would otherwise fill the page. When `status` is set, `total` +counts the matching sessions rather than every stored session. + +Caveat (spec 082): handshake-only sessions are not persisted, so a connected but +idle client does not appear until its first tool call. #### GET /api/v1/sessions/{id} diff --git a/docs/superpowers/plans/2026-07-29-tray-glance.md b/docs/superpowers/plans/2026-07-29-tray-glance.md new file mode 100644 index 000000000..1cbb91d09 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-tray-glance.md @@ -0,0 +1,6597 @@ +# Tray Glance Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a compact glance section to the top of the macOS tray menu showing the five most recent tool calls, the active MCP clients, and a 24-hour calls-per-hour histogram behind a submenu. + +**Architecture:** All text rows are plain `NSMenuItem`s — custom menu-item views receive mouse but not keyboard events, so only the histogram is a hosted SwiftUI view, and it lives in a submenu. Rendering reads `AppState` exclusively, so opening the menu performs no network I/O; three background feeds (an SSE activity path that consumes event payloads directly, a filtered activity poll, and a usage aggregate) keep that state fresh, and one small Go change adds the server-side `status` filter the client list needs to be truthful. + +**Tech Stack:** Go 1.24 (backend, `internal/httpapi` + `internal/storage` + `internal/runtime`); Swift 5.9 / AppKit + SwiftUI with SwiftUI Charts (tray, SwiftPM package at `native/macos/MCPProxy`, no `.xcodeproj`); XCTest for the native suite. + +**Design spec:** [`docs/superpowers/specs/2026-07-29-tray-glance-design.md`](../specs/2026-07-29-tray-glance-design.md) + +## Global Constraints + +Every task inherits these. They come from the design spec and the repo's own rules. + +- **Menu open stays network-free.** Spec 048's invariant: `menuWillOpen` renders from in-memory state only. Opening the histogram submenu is also fetch-free — its data arrives on the background loop. +- **Never narrow a shared `AppState` feed.** `recentActivity` backs the Dashboard's full activity log and `recentSessions` backs session-name lookup in `ActivityView` plus the Dashboard's session list. The glance section gets its own `glanceActivity` / `glanceSessions` fields; it never repoints or filters the shared ones. +- **The menu does not restructure while it is open.** While tracking, rows update in place and structural changes are deferred to `menuDidClose`. +- **In-place row updates rewrite the whole row identity** — title, image, tooltip, accessibility label, and `representedObject` — never the title alone. +- **The tray holds no state.** It renders what the core gives it; display policy (selection rules, formatting) is fine, derived or persisted tray-local state is not. +- **UI strings are English**, matching the rest of the menu. +- **No new dependencies.** SwiftUI Charts ships with the platform (`Package.swift` declares `.macOS(.v13)`); nothing is added to `go.mod` or the Swift package. +- **Commit messages:** conventional-commit prefix, `Related #` (never `Fixes #`), and **no** `Co-Authored-By: Claude` line and no "Generated with Claude Code" footer. +- **Generated artifacts are never hand-edited.** `oas/swagger.yaml` and friends are regenerated; CI verifies they match. + +--- + +## File Structure + +**Go — the `status` filter (Task 1)** + +| File | Responsibility | +|---|---| +| `internal/storage/manager.go` | `GetRecentSessions` gains a status argument applied *during* the cursor walk, before truncation | +| `internal/runtime/runtime.go` | pass-through of the new argument | +| `internal/server/server.go` | pass-through wrapper | +| `internal/httpapi/server.go` | parse and validate the `status` query param; `ServerController` interface update; swag annotations | +| `internal/httpapi/security_test.go`, `contracts_test.go` | mock controllers updated to the new signature | +| `oas/swagger.yaml` (+ generated siblings) | regenerated, never hand-edited | +| `docs/api/rest-api.md` | the `### Sessions` block documents the new parameter | + +**Swift — data layer (Tasks 2–4)** + +| File | Responsibility | +|---|---| +| `API/APIClient.swift` | three new calls (`activeSessions`, `glanceActivity`, `usageAggregate`) and the `last_activity` coding-key fix | +| `API/Models.swift` | usage-aggregate response models (`UsageBucket` et al.) | +| `Menu/Glance/GlanceDataSource.swift` | narrow protocol the glance component depends on, so a counting stub can be injected | +| `State/AppState.swift` | `glanceActivity`, `glanceSessions`, `usageTimeline`, `callsThisHour`, `usageError`; update helpers; `clearGlanceState()` | +| `Core/CoreProcessManager.swift` | wires the three fetches into the 30s loop; replaces the dead `case "activity"` with payload-consuming handlers; clears glance state on disconnect | +| `Menu/Glance/GlanceEvent.swift` | adapts an SSE payload envelope into an `ActivityEntry` (Unix seconds → `Date`, `internal_tool_name`/`target_server` mapping, `error_message`, composite provisional id) | + +**Swift — presentation (Tasks 5–8)** + +| File | Responsibility | +|---|---| +| `Menu/Glance/GlanceSelection.swift` | the ordered selection rules and the `request_id` collapse; active-session filtering | +| `Menu/Glance/GlanceFormatting.swift` | status icon, row label, middle truncation, relative time (salvaged from the deleted `TrayMenu.swift`) | +| `Menu/Glance/GlanceSection.swift` | builds the plain `NSMenuItem`s and the in-place update path | +| `Menu/Glance/MenuRebuildGuard.swift` | tracks whether the menu is open and whether a deferred rebuild is pending | +| `Menu/Glance/GlanceLinks.swift` | builds the authenticated Web UI activity URL for a session | +| `Menu/Glance/ActivityHistogramView.swift` | SwiftUI Charts stacked bar chart + its hosted menu item | +| `MCPProxyApp.swift` | inserts the section into `rebuildMenu()`, applies the tracking guard, adds the deep-link action | +| `Menu/TrayMenu.swift` | **deleted** (511 lines, dead) | + +**Tests** live in `native/macos/MCPProxy/MCPProxyTests/`: `APIClientGlanceTests`, `AppStateGlanceTests`, `GlanceEventTests`, `GlanceSelectionTests`, `GlanceSelectionCollapseTests`, `GlanceFormattingTests`, `GlanceSectionTests`, `ActivityHistogramTests`, plus the `CountingGlanceDataSource` and `GlanceStubURLProtocol` helpers. Both SwiftPM targets use path-based globbing, so new files need no target registration. + +--- + +### Task 1: Go — `status` filter on `GET /api/v1/sessions` + +The macOS tray's "Clients" glance section needs the sessions that are **active right now**. Today storage walks the sessions bucket newest-first **by start time** and truncates to `limit` (`internal/storage/manager.go:1358`), and only that already-truncated page is re-sorted by last activity in the runtime (`internal/runtime/runtime.go:1340`). A client that connected hours ago but is calling tools this second therefore falls outside any page once enough newer sessions exist — the tray would render "No connected clients" while a client is working. Filtering client-side cannot fix that, because the record never leaves storage. This task pushes the filter down into the cursor walk, **before** truncation. + +**Files:** + +- Modify: `internal/storage/manager.go` (lines 1338–1372 — `(*Manager).GetRecentSessions`) +- Modify: `internal/runtime/runtime.go` (lines 1309–1314 — `(*Runtime).GetRecentSessions`) +- Modify: `internal/server/server.go` (lines 3075–3078 — the pass-through wrapper) +- Modify: `internal/httpapi/server.go` (lines 108–109 — the `ServerController` interface; lines 4824–4886 — `handleGetSessions` + its swag annotations) +- Modify: `internal/httpapi/security_test.go` (lines 316–318 — `baseController` mock) +- Modify: `internal/httpapi/contracts_test.go` (lines 172–174 — `MockServerController` mock) +- Modify: `docs/api/rest-api.md` (lines 926–934 — the `### Sessions` block) +- Modify (regenerated, never hand-edited): `oas/swagger.yaml`, `oas/docs.go` +- Test: `internal/storage/sessions_filter_test.go` (create) +- Test: `internal/httpapi/sessions_handlers_test.go` (create) + +**Interfaces:** + +*Consumes:* nothing. This is the first task and depends on no earlier task. + +*Produces:* + +- HTTP contract — `GET /api/v1/sessions?status=active&limit=25` (auth: `X-API-Key` header) returns `200` with the existing envelope: + `{"success":true,"data":{"sessions":[…],"total":N,"limit":25,"offset":0}}`. + `status` accepts exactly `active` or `closed`; omitting it means no filter. Any other value returns `400` with `{"success":false,"error":"Invalid status. Use 'active' or 'closed'"}`. When `status` is set, `total` counts the **matching** sessions, not every stored session. +- `func (m *storage.Manager) GetRecentSessions(limit int, status string) ([]*storage.SessionRecord, int, error)` +- `func (r *runtime.Runtime) GetRecentSessions(limit int, status string) ([]*contracts.MCPSession, int, error)` +- `func (s *server.Server) GetRecentSessions(limit int, status string) ([]*contracts.MCPSession, int, error)` +- `httpapi.ServerController` method `GetRecentSessions(limit int, status string) ([]*contracts.MCPSession, int, error)` +- Session JSON field names, unchanged and pinned by this task's tests (`internal/contracts/types.go:234-255`): `id`, `client_name`, `client_version`, `status`, `start_time`, `end_time`, `last_activity`, `tool_call_count`, `total_tokens`, `has_roots`, `has_sampling`, `experimental`, `workspace_name`, `work_session_id`. Note the activity timestamp key is **`last_activity`**. + +--- + +- [ ] **Step 1: Write the failing storage test.** + +Create `internal/storage/sessions_filter_test.go` with exactly this content. It reuses `setupTestStorageForActivity` (already defined in `internal/storage/activity_test.go:13`, same package), so no new helper is needed. + +```go +package storage + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A session that started long ago but is still active must survive a small +// page. Storage walks newest-first by START time and truncates to `limit`, so +// filtering on the client after truncation would drop it entirely — the tray's +// "Clients" section would then say "No connected clients" while a client is +// actively calling tools. +func TestGetRecentSessions_StatusFilterAppliedBeforeTruncation(t *testing.T) { + manager, cleanup := setupTestStorageForActivity(t) + defer cleanup() + + base := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) + + // Oldest session is the active one. + require.NoError(t, manager.CreateSession(&SessionRecord{ + ID: "session-old-active", + ClientName: "claude-code", + Status: "active", + StartTime: base, + LastActivity: base.Add(90 * time.Minute), + })) + + // Four newer sessions, all closed. + for i := 1; i <= 4; i++ { + require.NoError(t, manager.CreateSession(&SessionRecord{ + ID: "session-closed-" + string(rune('a'+i-1)), + ClientName: "cursor", + Status: "closed", + StartTime: base.Add(time.Duration(i) * time.Minute), + LastActivity: base.Add(time.Duration(i) * time.Minute), + })) + } + + t.Run("unfiltered page of 2 misses the active session", func(t *testing.T) { + sessions, total, err := manager.GetRecentSessions(2, "") + require.NoError(t, err) + assert.Equal(t, 5, total, "unfiltered total is the whole bucket") + require.Len(t, sessions, 2) + for _, s := range sessions { + assert.Equal(t, "closed", s.Status) + } + }) + + t.Run("status=active returns the old active session despite the page size", func(t *testing.T) { + sessions, total, err := manager.GetRecentSessions(2, "active") + require.NoError(t, err) + assert.Equal(t, 1, total, "total counts matching records, not the bucket") + require.Len(t, sessions, 1) + assert.Equal(t, "session-old-active", sessions[0].ID) + }) + + t.Run("status=closed returns only closed sessions", func(t *testing.T) { + sessions, total, err := manager.GetRecentSessions(10, "closed") + require.NoError(t, err) + assert.Equal(t, 4, total) + require.Len(t, sessions, 4) + for _, s := range sessions { + assert.Equal(t, "closed", s.Status) + } + }) +} +``` + +- [ ] **Step 2: Run the storage test and watch it fail to compile.** + +```bash +cd /Users/user/repos/mcpproxy-go +go test ./internal/storage/ -run TestGetRecentSessions_StatusFilterAppliedBeforeTruncation -count=1 +``` + +Expected output (the method takes one argument today): + +``` +# github.com/smart-mcp-proxy/mcpproxy-go/internal/storage [github.com/smart-mcp-proxy/mcpproxy-go/internal/storage.test] +internal/storage/sessions_filter_test.go:43:56: too many arguments in call to manager.GetRecentSessions + have (number, string) + want (int) +internal/storage/sessions_filter_test.go:53:56: too many arguments in call to manager.GetRecentSessions + have (number, string) + want (int) +internal/storage/sessions_filter_test.go:61:57: too many arguments in call to manager.GetRecentSessions + have (number, string) + want (int) +FAIL github.com/smart-mcp-proxy/mcpproxy-go/internal/storage [build failed] +FAIL +``` + +- [ ] **Step 3: Implement the filter inside the storage cursor walk.** + +In `internal/storage/manager.go`, replace the whole existing block starting at line 1338 (`// GetRecentSessions returns the most recent sessions`) and ending at the closing `})` of the `View` callback — i.e. replace this exact text: + +```go +// GetRecentSessions returns the most recent sessions +func (m *Manager) GetRecentSessions(limit int) ([]*SessionRecord, int, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + var sessions []*SessionRecord + var total int + + err := m.db.db.View(func(tx *bbolt.Tx) error { + bucket := tx.Bucket([]byte(SessionsBucket)) + if bucket == nil { + return nil // No sessions yet + } + + // Count total + total = bucket.Stats().KeyN + + // Iterate in reverse (newest first due to timestamp key prefix) + c := bucket.Cursor() + count := 0 + for k, v := c.Last(); k != nil && count < limit; k, v = c.Prev() { + var session SessionRecord + if err := json.Unmarshal(v, &session); err != nil { + m.logger.Warnw("Failed to unmarshal session", "error", err) + continue + } + sessions = append(sessions, &session) + count++ + } + + return nil + }) +``` + +with: + +```go +// GetRecentSessions returns the most recent sessions. +// +// status filters on SessionRecord.Status ("active" / "closed"); an empty string +// means no filtering. The filter is applied DURING the cursor walk, before the +// limit truncates the result, so a session that started long ago but is still +// active is never dropped by a page full of newer sessions. Filtering after +// truncation would let the tray report "no connected clients" while a client +// is actively calling tools. +func (m *Manager) GetRecentSessions(limit int, status string) ([]*SessionRecord, int, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + var sessions []*SessionRecord + var total int + + err := m.db.db.View(func(tx *bbolt.Tx) error { + bucket := tx.Bucket([]byte(SessionsBucket)) + if bucket == nil { + return nil // No sessions yet + } + + if status == "" { + // Unfiltered: total is the whole bucket and the walk can stop early. + total = bucket.Stats().KeyN + + // Iterate in reverse (newest first due to timestamp key prefix) + c := bucket.Cursor() + count := 0 + for k, v := c.Last(); k != nil && count < limit; k, v = c.Prev() { + var session SessionRecord + if err := json.Unmarshal(v, &session); err != nil { + m.logger.Warnw("Failed to unmarshal session", "error", err) + continue + } + sessions = append(sessions, &session) + count++ + } + + return nil + } + + // Filtered: walk the whole bucket so `total` honestly counts matching + // records. Session retention caps this bucket at 100 keys + // (enforceSessionRetention), so a full walk is bounded and cheap. + c := bucket.Cursor() + for k, v := c.Last(); k != nil; k, v = c.Prev() { + var session SessionRecord + if err := json.Unmarshal(v, &session); err != nil { + m.logger.Warnw("Failed to unmarshal session", "error", err) + continue + } + if session.Status != status { + continue + } + total++ + if len(sessions) < limit { + sessions = append(sessions, &session) + } + } + + return nil + }) +``` + +Leave the trailing `return sessions, total, err` and closing brace untouched. (`var session SessionRecord` is declared inside the loop body, so `&session` is a fresh pointer each iteration — do not hoist it.) + +- [ ] **Step 4: Run the storage test and watch it pass.** + +```bash +cd /Users/user/repos/mcpproxy-go +go test ./internal/storage/ -run TestGetRecentSessions_StatusFilterAppliedBeforeTruncation -count=1 -v +``` + +Expected output: + +``` +=== RUN TestGetRecentSessions_StatusFilterAppliedBeforeTruncation +=== RUN TestGetRecentSessions_StatusFilterAppliedBeforeTruncation/unfiltered_page_of_2_misses_the_active_session +=== RUN TestGetRecentSessions_StatusFilterAppliedBeforeTruncation/status=active_returns_the_old_active_session_despite_the_page_size +=== RUN TestGetRecentSessions_StatusFilterAppliedBeforeTruncation/status=closed_returns_only_closed_sessions +--- PASS: TestGetRecentSessions_StatusFilterAppliedBeforeTruncation (0.09s) + --- PASS: TestGetRecentSessions_StatusFilterAppliedBeforeTruncation/unfiltered_page_of_2_misses_the_active_session (0.00s) + --- PASS: TestGetRecentSessions_StatusFilterAppliedBeforeTruncation/status=active_returns_the_old_active_session_despite_the_page_size (0.00s) + --- PASS: TestGetRecentSessions_StatusFilterAppliedBeforeTruncation/status=closed_returns_only_closed_sessions (0.00s) +PASS +ok github.com/smart-mcp-proxy/mcpproxy-go/internal/storage 0.422s +``` + +- [ ] **Step 5: See exactly which call sites the new signature broke.** + +```bash +cd /Users/user/repos/mcpproxy-go +go build ./... +``` + +Expected output: + +``` +# github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime +internal/runtime/runtime.go:1314:67: not enough arguments in call to r.storageManager.GetRecentSessions + have (int) + want (int, string) +``` + +(`go build` does not compile `_test.go` files, so the two httpapi mocks stay silent until Step 7.) + +- [ ] **Step 6: Thread `status` through the runtime and the server wrapper.** + +In `internal/runtime/runtime.go`, replace this exact text (line 1309 onward): + +```go +// GetRecentSessions returns recent MCP sessions +func (r *Runtime) GetRecentSessions(limit int) ([]*contracts.MCPSession, int, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + storageRecords, total, err := r.storageManager.GetRecentSessions(limit) +``` + +with: + +```go +// GetRecentSessions returns recent MCP sessions. +// +// status filters on the session status ("active" / "closed"); an empty string +// means no filtering. The filter is pushed down into the storage cursor walk so +// it is applied before truncation to limit. +func (r *Runtime) GetRecentSessions(limit int, status string) ([]*contracts.MCPSession, int, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + storageRecords, total, err := r.storageManager.GetRecentSessions(limit, status) +``` + +Then in `internal/server/server.go`, replace this exact text (line 3075 onward): + +```go +// GetRecentSessions retrieves recent MCP sessions +func (s *Server) GetRecentSessions(limit int) ([]*contracts.MCPSession, int, error) { + return s.runtime.GetRecentSessions(limit) +} +``` + +with: + +```go +// GetRecentSessions retrieves recent MCP sessions, optionally filtered by +// status ("active" / "closed"; empty means no filter). +func (s *Server) GetRecentSessions(limit int, status string) ([]*contracts.MCPSession, int, error) { + return s.runtime.GetRecentSessions(limit, status) +} +``` + +- [ ] **Step 7: Widen the `ServerController` interface and its two test mocks.** + +In `internal/httpapi/server.go`, replace this exact text (line 108): + +```go + // Session management + GetRecentSessions(limit int) ([]*contracts.MCPSession, int, error) +``` + +with: + +```go + // Session management. status filters on session status ("active" / + // "closed"); an empty string means no filter. + GetRecentSessions(limit int, status string) ([]*contracts.MCPSession, int, error) +``` + +Still in `internal/httpapi/server.go`, keep the handler compiling by passing an explicit empty filter for now — replace (line 4863): + +```go + sessions, total, err := s.controller.GetRecentSessions(limit) +``` + +with: + +```go + sessions, total, err := s.controller.GetRecentSessions(limit, "") +``` + +In `internal/httpapi/security_test.go` (line 316) replace: + +```go +func (m *baseController) GetRecentSessions(limit int) ([]*contracts.MCPSession, int, error) { +``` + +with: + +```go +func (m *baseController) GetRecentSessions(limit int, status string) ([]*contracts.MCPSession, int, error) { +``` + +In `internal/httpapi/contracts_test.go` (line 172) replace: + +```go +func (m *MockServerController) GetRecentSessions(_ int) ([]*contracts.MCPSession, int, error) { +``` + +with: + +```go +func (m *MockServerController) GetRecentSessions(_ int, _ string) ([]*contracts.MCPSession, int, error) { +``` + +Do **not** touch `internal/httpapi/code_exec_test.go:113` — that `mockController` is only ever passed to `httpapi.NewCodeExecHandler`, which takes a narrower interface, so its `GetRecentSessions(limit int) (interface{}, int, error)` stub is unrelated and still compiles. These four (`server.Server` plus the three test mocks) are the only `GetRecentSessions` implementations in the repo, including under the `server` build tag. + +- [ ] **Step 8: Confirm both editions build clean.** + +```bash +cd /Users/user/repos/mcpproxy-go +go build ./... && go build -tags server ./... && echo BUILD_OK +``` + +Expected output (a single line; anything else means a call site was missed): + +``` +BUILD_OK +``` + +- [ ] **Step 9: Write the failing handler test.** + +Create `internal/httpapi/sessions_handlers_test.go` with exactly this content. It follows the house pattern from `internal/httpapi/activity_handlers_test.go:24` — a mock embedding `baseController` and overriding `GetCurrentConfig() any`, a real `Server` built with `NewServer(ctrl, logger, nil)`, driven through `srv.ServeHTTP` with the `X-API-Key` header matching the mock's `GetCurrentConfig()`. + +```go +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" +) + +// mockSessionController records the status argument the handler pushes down, so +// the tests can prove the filter reaches storage rather than being applied (or +// dropped) in the handler. +type mockSessionController struct { + baseController + apiKey string + sessions []*contracts.MCPSession + gotLimit int + gotStatus string + callCount int +} + +func (m *mockSessionController) GetCurrentConfig() any { + return &config.Config{APIKey: m.apiKey} +} + +func (m *mockSessionController) GetRecentSessions(limit int, status string) ([]*contracts.MCPSession, int, error) { + m.callCount++ + m.gotLimit = limit + m.gotStatus = status + + var out []*contracts.MCPSession + for _, s := range m.sessions { + if status == "" || s.Status == status { + out = append(out, s) + } + } + return out, len(out), nil +} + +func testSessions() []*contracts.MCPSession { + start := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) + return []*contracts.MCPSession{ + {ID: "sess-active", ClientName: "claude-code", Status: "active", StartTime: start, LastActivity: start.Add(time.Hour)}, + {ID: "sess-closed", ClientName: "cursor", Status: "closed", StartTime: start.Add(time.Minute), LastActivity: start.Add(2 * time.Minute)}, + } +} + +func decodeSessionsResponse(t *testing.T, w *httptest.ResponseRecorder) contracts.GetSessionsResponse { + t.Helper() + var resp struct { + Success bool `json:"success"` + Data contracts.GetSessionsResponse `json:"data"` + } + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + require.True(t, resp.Success) + return resp.Data +} + +func TestGetSessions_StatusFilter(t *testing.T) { + logger := zap.NewNop().Sugar() + + t.Run("status=active is pushed down and narrows the result", func(t *testing.T) { + ctrl := &mockSessionController{apiKey: "test-key", sessions: testSessions()} + srv := NewServer(ctrl, logger, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions?status=active&limit=25", nil) + req.Header.Set("X-API-Key", "test-key") + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "active", ctrl.gotStatus, "the status filter must reach the controller, not be applied in the handler") + assert.Equal(t, 25, ctrl.gotLimit) + + data := decodeSessionsResponse(t, w) + require.Len(t, data.Sessions, 1) + assert.Equal(t, "sess-active", data.Sessions[0].ID) + assert.Equal(t, 1, data.Total) + assert.Equal(t, 25, data.Limit) + }) + + t.Run("no status parameter means no filter", func(t *testing.T) { + ctrl := &mockSessionController{apiKey: "test-key", sessions: testSessions()} + srv := NewServer(ctrl, logger, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions", nil) + req.Header.Set("X-API-Key", "test-key") + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "", ctrl.gotStatus) + assert.Equal(t, 10, ctrl.gotLimit, "default limit for sessions is 10") + + data := decodeSessionsResponse(t, w) + assert.Len(t, data.Sessions, 2) + }) + + t.Run("status=closed selects closed sessions", func(t *testing.T) { + ctrl := &mockSessionController{apiKey: "test-key", sessions: testSessions()} + srv := NewServer(ctrl, logger, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions?status=closed", nil) + req.Header.Set("X-API-Key", "test-key") + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + data := decodeSessionsResponse(t, w) + require.Len(t, data.Sessions, 1) + assert.Equal(t, "sess-closed", data.Sessions[0].ID) + }) + + t.Run("unknown status is rejected and never reaches the controller", func(t *testing.T) { + ctrl := &mockSessionController{apiKey: "test-key", sessions: testSessions()} + srv := NewServer(ctrl, logger, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions?status=bogus", nil) + req.Header.Set("X-API-Key", "test-key") + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, 0, ctrl.callCount, "a rejected filter must not fall through to an unfiltered query") + + var errResp contracts.ErrorResponse + require.NoError(t, json.NewDecoder(w.Body).Decode(&errResp)) + assert.Contains(t, errResp.Error, "Invalid status") + }) +} +``` + +- [ ] **Step 10: Run the handler test and watch it fail.** + +```bash +cd /Users/user/repos/mcpproxy-go +go test ./internal/httpapi/ -run TestGetSessions_StatusFilter -count=1 +``` + +Expected output (three of the four sub-tests fail; the handler still hardcodes `""` and never validates). testify's `Error Trace:` / `Test:` / `Diff:` lines are elided below for brevity — what matters is *which* assertions fail and where: + +``` +--- FAIL: TestGetSessions_StatusFilter (0.00s) + --- FAIL: TestGetSessions_StatusFilter/status=active_is_pushed_down_and_narrows_the_result (0.00s) + sessions_handlers_test.go:81: + Error: Not equal: + expected: "active" + actual : "" + Messages: the status filter must reach the controller, not be applied in the handler + sessions_handlers_test.go:85: + Error: "[{sess-active …} {sess-closed …}]" should have 1 item(s), but has 2 + --- FAIL: TestGetSessions_StatusFilter/status=closed_selects_closed_sessions (0.00s) + sessions_handlers_test.go:121: + Error: "[{sess-active …} {sess-closed …}]" should have 1 item(s), but has 2 + --- FAIL: TestGetSessions_StatusFilter/unknown_status_is_rejected_and_never_reaches_the_controller (0.00s) + sessions_handlers_test.go:135: + Error: Not equal: + expected: 400 + actual : 200 + sessions_handlers_test.go:136: + Error: Not equal: + expected: 0 + actual : 1 + Messages: a rejected filter must not fall through to an unfiltered query + sessions_handlers_test.go:140: + Error: "" does not contain "Invalid status" +FAIL +FAIL github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi 0.515s +FAIL +``` + +- [ ] **Step 11: Parse and validate `status` in the handler.** + +In `internal/httpapi/server.go`, inside `handleGetSessions`, replace this exact text: + +```go + offset := 0 + if offsetStr != "" { + if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed >= 0 { + offset = parsed + } + } + + // Get recent sessions from controller + sessions, total, err := s.controller.GetRecentSessions(limit, "") +``` + +with: + +```go + offset := 0 + if offsetStr != "" { + if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed >= 0 { + offset = parsed + } + } + + // Status filter. The domain is closed ("active" / "closed"), so an unknown + // value is a client bug — rejecting it is honest, where silently ignoring it + // would return unfiltered sessions that the caller believes are filtered. + status := r.URL.Query().Get("status") + if status != "" && status != "active" && status != "closed" { + s.writeError(w, r, http.StatusBadRequest, "Invalid status. Use 'active' or 'closed'") + return + } + + // Get recent sessions from controller + sessions, total, err := s.controller.GetRecentSessions(limit, status) +``` + +(`limit` keeps its existing lenient parsing — out-of-range values silently fall back to 10. Only `status` is strict, because unlike a clamped number a wrong enum silently changes *which* records come back.) + +- [ ] **Step 12: Add the swag annotations for the new parameter and the new failure mode.** + +Still in `internal/httpapi/server.go`, in the `// handleGetSessions godoc` comment block above the function, replace this exact text: + +```go +// @Param limit query int false "Maximum number of sessions to return (1-100, default 10)" +// @Param offset query int false "Number of sessions to skip for pagination (default 0)" +// @Success 200 {object} contracts.GetSessionsResponse "Sessions retrieved successfully" +// @Failure 401 {object} contracts.ErrorResponse "Unauthorized - missing or invalid API key" +``` + +with: + +```go +// @Param limit query int false "Maximum number of sessions to return (1-100, default 10)" +// @Param offset query int false "Number of sessions to skip for pagination (default 0)" +// @Param status query string false "Filter by session status" Enums(active, closed) +// @Success 200 {object} contracts.GetSessionsResponse "Sessions retrieved successfully" +// @Failure 400 {object} contracts.ErrorResponse "Invalid status filter" +// @Failure 401 {object} contracts.ErrorResponse "Unauthorized - missing or invalid API key" +``` + +- [ ] **Step 13: Run the handler test and watch it pass.** + +```bash +cd /Users/user/repos/mcpproxy-go +go test ./internal/httpapi/ -run TestGetSessions_StatusFilter -count=1 -v +``` + +Expected output: + +``` +=== RUN TestGetSessions_StatusFilter +=== RUN TestGetSessions_StatusFilter/status=active_is_pushed_down_and_narrows_the_result +=== RUN TestGetSessions_StatusFilter/no_status_parameter_means_no_filter +=== RUN TestGetSessions_StatusFilter/status=closed_selects_closed_sessions +=== RUN TestGetSessions_StatusFilter/unknown_status_is_rejected_and_never_reaches_the_controller +--- PASS: TestGetSessions_StatusFilter (0.00s) + --- PASS: TestGetSessions_StatusFilter/status=active_is_pushed_down_and_narrows_the_result (0.00s) + --- PASS: TestGetSessions_StatusFilter/no_status_parameter_means_no_filter (0.00s) + --- PASS: TestGetSessions_StatusFilter/status=closed_selects_closed_sessions (0.00s) + --- PASS: TestGetSessions_StatusFilter/unknown_status_is_rejected_and_never_reaches_the_controller (0.00s) +PASS +ok github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi 0.468s +``` + +- [ ] **Step 14: Regenerate the OpenAPI artifacts.** + +CI job `verify-oas` (in both `.github/workflows/unit-tests.yml` and `.github/workflows/pr-build.yml`) runs `scripts/verify-oas.sh`, which reruns `make swagger` and fails the build if `oas/swagger.yaml` or `oas/docs.go` come out dirty, so they must be regenerated and committed. `make swagger` expects the binary at `$HOME/go/bin/swag`; if it is missing, install it first with `go install github.com/swaggo/swag/v2/cmd/swag@v2.0.0-rc4`. + +```bash +cd /Users/user/repos/mcpproxy-go +make swagger +git diff --stat -- oas/ +``` + +Expected output (tail of `make swagger`, then the diffstat): + +``` +create docs.go at oas/docs.go +create swagger.yaml at oas/swagger.yaml +✅ OpenAPI 3.1 spec generated: oas/swagger.yaml and oas/docs.go + oas/docs.go | 2 +- + oas/swagger.yaml | 14 ++++++++++++++ + 2 files changed, 15 insertions(+), 1 deletion(-) +``` + +(`oas/docs.go` embeds the whole spec on a single line, so a one-line change there is expected.) + +- [ ] **Step 15: Eyeball the generated spec diff.** + +```bash +cd /Users/user/repos/mcpproxy-go +git diff -- oas/swagger.yaml +``` + +Expected output: + +``` +@@ -6027,6 +6027,14 @@ paths: + name: offset + schema: + type: integer ++ - description: Filter by session status ++ in: query ++ name: status ++ schema: ++ enum: ++ - active ++ - closed ++ type: string + responses: + "200": +@@ -6034,6 +6042,12 @@ paths: + schema: + $ref: '#/components/schemas/contracts.GetSessionsResponse' + description: Sessions retrieved successfully ++ "400": ++ content: ++ application/json: ++ schema: ++ $ref: '#/components/schemas/contracts.ErrorResponse' ++ description: Invalid status filter + "401": +``` + +If the diff touches any other path, a stray edit crept in — revert `oas/` and rerun `make swagger` on a clean tree. + +- [ ] **Step 16: Document the parameter in the REST API reference.** + +In `docs/api/rest-api.md` (the `### Sessions` block, lines 926–934), replace this exact text: + +```markdown +#### GET /api/v1/sessions + +List active MCP sessions. + +#### GET /api/v1/sessions/{id} +``` + +with: + +```markdown +#### GET /api/v1/sessions + +List recent MCP sessions. + +**Query Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `limit` | integer | Max sessions (1-100, default: 10) | +| `offset` | integer | Pagination offset (default: 0) | +| `status` | string | Filter by session status: `active`, `closed`. Any other value returns `400`. | + +The `status` filter is applied during the storage walk, **before** the `limit` +truncation, so a long-running session that is still active is returned even when +newer sessions would otherwise fill the page. When `status` is set, `total` +counts the matching sessions rather than every stored session. + +Caveat (spec 082): handshake-only sessions are not persisted, so a connected but +idle client does not appear until its first tool call. + +#### GET /api/v1/sessions/{id} +``` + +- [ ] **Step 17: Run the full gate for the two touched packages.** + +Check formatting on the files this task touched, then run the packages. Do **not** run `gofmt -l internal/` — roughly 29 files in this repo are already unformatted on a clean tree (`internal/tui/model.go`, `internal/tui/styles.go`, `internal/oauth/discovery.go`, `internal/oauth/refresh_manager.go`, `internal/security/pattern.go`, `internal/httpapi/swagger.go`, `internal/httpapi/activity_handlers_test.go`, `internal/configimport/cursor.go`, …), so a package-wide listing is pre-existing noise, not a signal about this change. + +```bash +cd /Users/user/repos/mcpproxy-go +gofmt -l internal/storage/manager.go internal/storage/sessions_filter_test.go \ + internal/runtime/runtime.go internal/server/server.go \ + internal/httpapi/server.go internal/httpapi/sessions_handlers_test.go \ + internal/httpapi/security_test.go internal/httpapi/contracts_test.go +go test ./internal/httpapi/ ./internal/storage/ -count=1 +``` + +Expected output (`gofmt -l` prints nothing for the touched files): + +``` +ok github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi 0.738s +ok github.com/smart-mcp-proxy/mcpproxy-go/internal/storage 6.583s +``` + +- [ ] **Step 18: Run the strict CI linter.** + +CI uses golangci-lint **v2** with `.github/.golangci.yml`, which is stricter than the local `scripts/run-linter.sh`. This takes ~2–4 minutes on a cold cache. + +```bash +cd /Users/user/repos/mcpproxy-go +/opt/homebrew/bin/golangci-lint run --config .github/.golangci.yml ./internal/httpapi/... ./internal/storage/... ./internal/runtime/... ./internal/server/... +``` + +Expected output: + +``` +0 issues. +``` + +- [ ] **Step 19: Commit.** + +```bash +cd /Users/user/repos/mcpproxy-go +git add internal/storage/manager.go internal/storage/sessions_filter_test.go \ + internal/runtime/runtime.go internal/server/server.go \ + internal/httpapi/server.go internal/httpapi/sessions_handlers_test.go \ + internal/httpapi/security_test.go internal/httpapi/contracts_test.go \ + oas/swagger.yaml oas/docs.go docs/api/rest-api.md +git commit -m "$(cat <<'EOF' +feat(api): add status filter to GET /api/v1/sessions + +Storage walked the sessions bucket newest-first by START time and truncated +to limit before anyone could filter, so a session that began hours ago but is +still active fell outside every page once enough newer sessions existed. The +macOS tray's client glance would then show "No connected clients" while a +client was actively calling tools, and offset cannot recover it (the handler +parses offset but never passes it down). + +Push the filter into the cursor walk so it runs before truncation: + +- storage.Manager.GetRecentSessions(limit, status) filters on SessionRecord + .Status during the walk. When filtering, it walks the whole bucket (capped + at 100 keys by session retention) so `total` counts matching records. +- runtime.Runtime / server.Server / httpapi.ServerController thread status + through unchanged. +- The handler validates status against the closed domain (active|closed) and + returns 400 on anything else rather than silently returning unfiltered + sessions the caller believes are filtered. + +Regenerates the OpenAPI artifacts and documents the parameter. +EOF +)" +``` + +Verify the commit landed and that the OAS artifacts are now considered up to date: + +```bash +cd /Users/user/repos/mcpproxy-go +git show --stat --oneline HEAD | head -15 +make swagger-verify 2>&1 | tail -2 +``` + +Expected output — 11 files in the commit, and: + +``` +✅ OpenAPI 3.1 spec generated: oas/swagger.yaml and oas/docs.go +✅ OpenAPI artifacts are up to date. +``` + +--- + +### Task 2: Swift — API client methods, models, and the glance data-source protocol + +**Files:** +- Create: `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceDataSource.swift` +- Create: `native/macos/MCPProxy/MCPProxyTests/GlanceStubURLProtocol.swift` +- Create: `native/macos/MCPProxy/MCPProxyTests/CountingGlanceDataSource.swift` +- Create: `native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift` +- Modify: `native/macos/MCPProxy/MCPProxy/API/Models.swift` (append after line 1056, end of file) +- Modify: `native/macos/MCPProxy/MCPProxy/API/APIClient.swift` (lines 341, 353 — `MCPSession` field + coding key; insert after line 368 — `activeSessions`; insert before line 378 — `glanceActivity` + `usageAggregate`) +- Modify: `native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift` (lines 481, 482, 494, 495, 553 — `.lastActive` → `.lastActivity`) +- Delete (progressively, Steps 0/9/14): `native/macos/MCPProxy/MCPProxy/API/UsageStub.swift` — the untracked scratch placeholder this task supersedes +- Test: `native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift` + +> **No `Package.swift` edit is needed.** `native/macos/MCPProxy/Package.swift` declares both targets with `path:`-based globbing (`path: "MCPProxy"` and `path: "MCPProxyTests"`), so dropping a new `.swift` file into any subdirectory of those paths is enough for `swift test` to pick it up. + +> **Working-tree note.** This task lands on top of an uncommitted spike. `MCPProxy/API/UsageStub.swift` (untracked) already declares `UsageBucket`, `UsageAggregateResponse` **and** an `extension APIClient` carrying all three methods below; `MCPProxy/Core/CoreProcessManager.swift` (lines 875, 888, 900) and `MCPProxy/State/AppState.swift` (lines 102, 308, 334) call into them. Step 0 retires that placeholder one shim at a time so that every "watch it fail" step has a real red state and every "watch it pass" step actually compiles. If your checkout has no `UsageStub.swift` and no spike wiring, Step 0 is a no-op and the red states are the test-target compile errors noted in each step. + +**Interfaces:** + +*Consumes (from Task 1):* +- `GET /api/v1/sessions?status=active&limit=25` — the Go handler + storage cursor filter that returns only sessions whose `Status == "active"`. Response envelope is unchanged: `{"success":true,"data":{"sessions":[…],"total":N,"limit":N,"offset":N}}`. +- Nothing in this task *compiles* against Task 1 — the Swift tests drive a stubbed `URLProtocol`, so Task 2 can be implemented and merged before or after Task 1. + +*Produces (relied on by later tasks):* +```swift +// native/macos/MCPProxy/MCPProxy/API/Models.swift +struct UsageBucket: Codable, Equatable { + let start: Date // UTC-hour aligned + let calls: Int // INCLUDES `errors` + let errors: Int + let totalRespBytes: Int +} +extension UsageBucket { + static func parseRFC3339(_ value: String) -> Date? + static func rfc3339String(from date: Date) -> String +} +struct UsageAggregateResponse: Codable, Equatable { + let window: String + let tokenSource: String? + let tokensSaved: Int? + let tokensSavedPercentage: Double? + let timeline: [UsageBucket] +} + +// native/macos/MCPProxy/MCPProxy/API/APIClient.swift (all actor-isolated → call sites need `await`) +func usageAggregate(window: String = "24h", top: Int = 1) async throws -> UsageAggregateResponse +func glanceActivity(limit: Int = 50) async throws -> [ActivityEntry] +func activeSessions(limit: Int = 25) async throws -> [MCPSession] +// APIClient.MCPSession gains `let lastActivity: String?` (replaces `lastActive`) + +// native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceDataSource.swift +protocol GlanceDataSource { + func usageAggregate(window: String, top: Int) async throws -> UsageAggregateResponse + func glanceActivity(limit: Int) async throws -> [ActivityEntry] + func activeSessions(limit: Int) async throws -> [APIClient.MCPSession] +} +extension APIClient: GlanceDataSource {} + +// native/macos/MCPProxy/MCPProxyTests/CountingGlanceDataSource.swift (test target only) +final class CountingGlanceDataSource: GlanceDataSource { + private(set) var usageCallCount: Int + private(set) var activityCallCount: Int + private(set) var sessionCallCount: Int + var totalCallCount: Int + var usageToReturn: UsageAggregateResponse + var activityToReturn: [ActivityEntry] + var sessionsToReturn: [APIClient.MCPSession] +} + +// native/macos/MCPProxy/MCPProxyTests/GlanceStubURLProtocol.swift (test target only) +final class GlanceStubURLProtocol: URLProtocol { + static var requestedURLs: [String] + static var responseBody: Data + static var statusCode: Int + static func reset() + static func makeClient() -> APIClient + static func envelope(_ json: String) -> Data +} +``` + +--- + +- [ ] **Step 0: Retire the scratch placeholder down to the two shims this task has not replaced yet.** + + First confirm what is actually in the tree: + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && \ + cat MCPProxy/API/UsageStub.swift && \ + grep -n "usageAggregate\|glanceActivity(limit\|activeSessions(limit\|\[UsageBucket\]" \ + MCPProxy/Core/CoreProcessManager.swift MCPProxy/State/AppState.swift + ``` + + Expect `UsageStub.swift` to declare `UsageBucket`, `UsageAggregateResponse` and an `extension APIClient` with all three methods, and the grep to report `CoreProcessManager.swift:875/888/900` plus `AppState.swift:102/308/334`. Those spike call sites are why the placeholder cannot simply be deleted in one go: `glanceActivity` is not reinstated until Step 10 and `activeSessions` not until Step 16, so a wholesale `rm` here would leave the `MCPProxy` module uncompilable through Steps 6 and 11. + + Replace the file with just the shims Task 2 has not reached yet — dropping the two type declarations and the `usageAggregate` shim, which Steps 4 and 5 re-create: + + ```swift + // UsageStub.swift + // MCPProxy + // + // TRANSITIONAL — deleted by Step 14. Holds the glance API shims that Task 2 has + // not implemented yet so the CoreProcessManager wiring keeps compiling between + // TDD cycles. Each shim is removed in the step that replaces it for real. + + import Foundation + + extension APIClient { + func glanceActivity(limit: Int = 50) async throws -> [ActivityEntry] { _ = limit; return [] } + func activeSessions(limit: Int = 25) async throws -> [MCPSession] { _ = limit; return [] } + } + ``` + + The file is untracked, so none of these edits (nor its eventual deletion) need staging in the commits below. Leave `CoreProcessManager.swift` and `AppState.swift` unstaged throughout — they belong to sibling tasks. + +- [ ] **Step 1: Create the URLProtocol stub that records request URLs.** + + `APIClient` has an unused test seam — `init(session:baseURL:apiKey:)` at `APIClient.swift:66` (verified: no call sites anywhere in `MCPProxy/` or `MCPProxyTests/`). This stub is what makes it useful: it intercepts every request on an injected `URLSession`, records the absolute URL, and replays a canned JSON body. Create `native/macos/MCPProxy/MCPProxyTests/GlanceStubURLProtocol.swift` with exactly: + + ```swift + // GlanceStubURLProtocol.swift + // MCPProxyTests + // + // A URLProtocol that records every request URL and replays a canned JSON body, + // so APIClient's request building and decoding can be tested without a core. + + import Foundation + @testable import MCPProxy + + final class GlanceStubURLProtocol: URLProtocol { + + /// Absolute URL strings seen by the stub, in request order. + static var requestedURLs: [String] = [] + + /// Body replayed for every request. + static var responseBody = Data() + + /// Status code replayed for every request. + static var statusCode = 200 + + static func reset() { + requestedURLs = [] + responseBody = Data() + statusCode = 200 + } + + /// An APIClient whose traffic is intercepted by this stub. + static func makeClient() -> APIClient { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GlanceStubURLProtocol.self] + return APIClient( + session: URLSession(configuration: config), + baseURL: "http://127.0.0.1:8080", + apiKey: nil + ) + } + + /// Wrap a payload in the standard `{"success":true,"data":…}` envelope. + static func envelope(_ json: String) -> Data { + Data("{\"success\":true,\"data\":\(json)}".utf8) + } + + override class func canInit(with request: URLRequest) -> Bool { true } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + if let url = request.url { + GlanceStubURLProtocol.requestedURLs.append(url.absoluteString) + } + let response = HTTPURLResponse( + url: request.url ?? URL(string: "http://127.0.0.1:8080")!, + statusCode: GlanceStubURLProtocol.statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: GlanceStubURLProtocol.responseBody) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + } + ``` + + Note the stub deliberately does **not** register `SocketURLProtocol`, so the Unix-socket transport (`MCPProxy/Core/SocketTransport.swift:483`) is bypassed entirely. + +- [ ] **Step 2: Write the three failing usage-aggregate tests.** + + Create `native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift` with exactly: + + ```swift + import XCTest + @testable import MCPProxy + + /// Request-shape and decoding tests for the three tray-glance API calls. + final class APIClientGlanceTests: XCTestCase { + + override func setUp() { + super.setUp() + GlanceStubURLProtocol.reset() + } + + override func tearDown() { + GlanceStubURLProtocol.reset() + super.tearDown() + } + + // MARK: - Usage aggregate + + func testUsageAggregateRequestsWindowAndTop() async throws { + GlanceStubURLProtocol.responseBody = GlanceStubURLProtocol.envelope(""" + {"window":"24h","token_source":"bytes","tokens_saved":184320, + "tokens_saved_percentage":92.4,"tools":[],"timeline":[]} + """) + let client = GlanceStubURLProtocol.makeClient() + + _ = try await client.usageAggregate() + + XCTAssertEqual( + GlanceStubURLProtocol.requestedURLs, + ["http://127.0.0.1:8080/api/v1/activity/usage?window=24h&top=1"] + ) + } + + func testUsageAggregateDecodesTimelineBuckets() async throws { + GlanceStubURLProtocol.responseBody = GlanceStubURLProtocol.envelope(""" + {"window":"24h","token_source":"bytes","tokens_saved":0, + "tokens_saved_percentage":0,"tools":[],"timeline":[ + {"start":"2026-07-29T13:00:00Z","calls":12,"errors":2,"total_resp_bytes":4096} + ]} + """) + let client = GlanceStubURLProtocol.makeClient() + + let usage = try await client.usageAggregate() + + XCTAssertEqual(usage.window, "24h") + XCTAssertEqual(usage.tokensSaved, 0) + XCTAssertEqual(usage.timeline.count, 1) + let bucket = try XCTUnwrap(usage.timeline.first) + XCTAssertEqual(bucket.calls, 12) + XCTAssertEqual(bucket.errors, 2) + XCTAssertEqual(bucket.totalRespBytes, 4096) + // 2026-07-29T13:00:00Z as seconds since the epoch. + XCTAssertEqual(bucket.start.timeIntervalSince1970, 1785330000, accuracy: 0.5) + } + + func testUsageBucketDecodesFractionalSecondTimestamps() throws { + let json = Data(""" + {"start":"2026-07-29T13:00:00.123Z","calls":1,"errors":0,"total_resp_bytes":0} + """.utf8) + + let bucket = try JSONDecoder().decode(UsageBucket.self, from: json) + + // Tolerance must stay well under the .123s being asserted, or the test + // passes even when the fractional-seconds branch is dropped. + XCTAssertEqual(bucket.start.timeIntervalSince1970, 1785330000.123, accuracy: 0.001) + } + } + ``` + +- [ ] **Step 3: Run the usage tests and watch them fail to compile.** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter APIClientGlanceTests 2>&1 | grep -E "error:" | sort -u + ``` + + Expected output (a Swift compile failure *is* the red state — the symbols do not exist yet). Because Step 0 removed the types and the `usageAggregate` shim, the **main** target fails first, so SwiftPM never gets as far as compiling the test target; these are the errors you see (columns will vary): + + ``` + …/MCPProxy/Core/CoreProcessManager.swift:900: error: value of type 'APIClient' has no member 'usageAggregate' + …/MCPProxy/State/AppState.swift:102: error: cannot find type 'UsageBucket' in scope + …/MCPProxy/State/AppState.swift:308: error: cannot find type 'UsageBucket' in scope + …/MCPProxy/State/AppState.swift:334: error: cannot find type 'UsageBucket' in scope + error: fatalError + ``` + + On a clean checkout with no spike wiring, the main target builds and you get the test-target errors instead: `no member 'usageAggregate'` at the two call sites, `generic parameter 'T' could not be inferred` at the `XCTUnwrap`, and `cannot find 'UsageBucket' in scope`. Either way the cycle is red. + +- [ ] **Step 4: Add the usage models to `Models.swift`.** + + Append to the end of `native/macos/MCPProxy/MCPProxy/API/Models.swift` (after line 1056): + + ```swift + + // MARK: - Usage Aggregate (Spec 069 A3) + + /// One hourly bar of the usage timeline. + /// Matches Go `contracts.UsageTimeBucket`. + /// + /// `calls` INCLUDES `errors` — a stacked chart must plot `calls - errors` and + /// `errors`, never the two raw fields, or failures are counted twice. + /// Buckets are UTC-hour aligned and sparse: hours with no activity are omitted. + struct UsageBucket: Codable, Equatable { + /// Start of the UTC hour this bucket covers. + let start: Date + let calls: Int + let errors: Int + let totalRespBytes: Int + + enum CodingKeys: String, CodingKey { + case start, calls, errors + case totalRespBytes = "total_resp_bytes" + } + } + + // The Codable conformance lives in an extension so the memberwise initialiser + // is still synthesised (an `init` in the struct body would suppress it). + extension UsageBucket { + /// The API emits Go's RFC 3339 rendering (`2026-07-29T13:00:00Z`), which the + /// shared `JSONDecoder` in `fetchWrapped` cannot parse with its default + /// `.deferredToDate` strategy. Parsing here keeps the model self-contained + /// instead of forcing a decoder-wide date strategy onto every other model. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let raw = try container.decode(String.self, forKey: .start) + guard let parsed = UsageBucket.parseRFC3339(raw) else { + throw DecodingError.dataCorruptedError( + forKey: .start, in: container, + debugDescription: "Not an RFC 3339 timestamp: \(raw)" + ) + } + let calls = try container.decode(Int.self, forKey: .calls) + let errors = try container.decode(Int.self, forKey: .errors) + let bytes = try container.decode(Int.self, forKey: .totalRespBytes) + self.init(start: parsed, calls: calls, errors: errors, totalRespBytes: bytes) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(UsageBucket.rfc3339String(from: start), forKey: .start) + try container.encode(calls, forKey: .calls) + try container.encode(errors, forKey: .errors) + try container.encode(totalRespBytes, forKey: .totalRespBytes) + } + + /// Parse an RFC 3339 timestamp, with or without fractional seconds. + static func parseRFC3339(_ value: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: value) { return date } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: value) + } + + /// Render a date the way the API renders bucket starts. + static func rfc3339String(from date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: date) + } + } + + /// Response for `GET /api/v1/activity/usage`. + /// Matches Go `contracts.UsageAggregateResponse`. + /// + /// The per-tool rollup (`tools`, `other`) and `generated_at` are deliberately not + /// decoded: the tray requests `top=1` and renders only the timeline plus the + /// tokens-saved headline. Unknown keys are ignored by `JSONDecoder`. + struct UsageAggregateResponse: Codable, Equatable { + let window: String + let tokenSource: String? + let tokensSaved: Int? + let tokensSavedPercentage: Double? + /// Global hourly buckets, trimmed to `window`. Never nil; empty when idle. + let timeline: [UsageBucket] + + enum CodingKeys: String, CodingKey { + case window, timeline + case tokenSource = "token_source" + case tokensSaved = "tokens_saved" + case tokensSavedPercentage = "tokens_saved_percentage" + } + } + ``` + + > The duplicate declarations of these two types were already removed from the scratch `UsageStub.swift` in Step 0. If you skipped Step 0, do it now — two declarations of the same type in one target will not compile. + +- [ ] **Step 5: Add `usageAggregate(window:top:)` to `APIClient`.** + + In `native/macos/MCPProxy/MCPProxy/API/APIClient.swift`, insert immediately **before** the line ` /// Fetch the activity summary from `GET /api/v1/activity/summary`.` (currently line 378): + + ```swift + /// Fetch the usage aggregate from `GET /api/v1/activity/usage`. + /// + /// Served from an in-memory snapshot behind a short TTL cache — never a log + /// scan. `top` trims the per-tool rollup the tray does not render; the + /// timeline is global and unaffected by it. + func usageAggregate(window: String = "24h", top: Int = 1) async throws -> UsageAggregateResponse { + return try await fetchWrapped(path: "/api/v1/activity/usage?window=\(window)&top=\(top)") + } + + ``` + + `fetchWrapped` (private, `APIClient.swift:606`) already unwraps the `{"success":…,"data":…}` envelope and falls back to a bare decode, so no other plumbing is needed. `window=24h` and `top=1` are both accepted by `parseUsageParams` (`internal/httpapi/activity.go:730`), which rejects only a non-integer or `< 1` `top`. + +- [ ] **Step 6: Run the usage tests and watch them pass.** + + The `glanceActivity` / `activeSessions` shims left in `UsageStub.swift` by Step 0 keep the main target compiling, so this checkpoint is genuinely green. + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter APIClientGlanceTests 2>&1 | grep -E "Test Case|Executed [0-9]+ tests" + ``` + + Expected output: + + ``` + Test Case '-[MCPProxyTests.APIClientGlanceTests testUsageAggregateDecodesTimelineBuckets]' started. + Test Case '-[MCPProxyTests.APIClientGlanceTests testUsageAggregateDecodesTimelineBuckets]' passed (0.002 seconds). + Test Case '-[MCPProxyTests.APIClientGlanceTests testUsageAggregateRequestsWindowAndTop]' started. + Test Case '-[MCPProxyTests.APIClientGlanceTests testUsageAggregateRequestsWindowAndTop]' passed (0.000 seconds). + Test Case '-[MCPProxyTests.APIClientGlanceTests testUsageBucketDecodesFractionalSecondTimestamps]' started. + Test Case '-[MCPProxyTests.APIClientGlanceTests testUsageBucketDecodesFractionalSecondTimestamps]' passed (0.000 seconds). + Executed 3 tests, with 0 failures (0 unexpected) in 0.002 (0.002) seconds + ``` + +- [ ] **Step 7: Commit the usage aggregate call.** + + Stage only these four paths — `UsageStub.swift` is untracked (nothing to stage), and `CoreProcessManager.swift` / `AppState.swift` belong to sibling tasks and must stay unstaged. Do not use `git add -A`. + + ```bash + cd /Users/user/repos/mcpproxy-go && \ + git add native/macos/MCPProxy/MCPProxy/API/Models.swift \ + native/macos/MCPProxy/MCPProxy/API/APIClient.swift \ + native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift \ + native/macos/MCPProxy/MCPProxyTests/GlanceStubURLProtocol.swift && \ + git commit -m "feat(tray): add usage-aggregate API call and response models + + GET /api/v1/activity/usage?window=24h&top=1 feeds the tray glance header + and 24h histogram. UsageBucket parses the RFC 3339 bucket start itself so + the shared fetchWrapped decoder needs no date strategy. + + Adds the first APIClient unit tests, via a URLProtocol stub on the + existing init(session:) seam." + ``` + +- [ ] **Step 8: Write the two failing glance-activity tests.** + + Append inside the `APIClientGlanceTests` class in `native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift`, after `testUsageBucketDecodesFractionalSecondTimestamps`: + + ```swift + + // MARK: - Glance activity + + func testGlanceActivityRequestsToolCallTypesWithOversizedPage() async throws { + GlanceStubURLProtocol.responseBody = GlanceStubURLProtocol.envelope(""" + {"activities":[],"total":0,"limit":50,"offset":0} + """) + let client = GlanceStubURLProtocol.makeClient() + + _ = try await client.glanceActivity() + + XCTAssertEqual( + GlanceStubURLProtocol.requestedURLs, + ["http://127.0.0.1:8080/api/v1/activity?type=tool_call,internal_tool_call&limit=50"] + ) + } + + func testGlanceActivityDecodesEntries() async throws { + GlanceStubURLProtocol.responseBody = GlanceStubURLProtocol.envelope(""" + {"activities":[ + {"id":"01J","type":"tool_call","status":"error","timestamp":"2026-07-29T13:04:05Z", + "server_name":"jira","tool_name":"get_issue","error_message":"auth failed", + "request_id":"req-1"} + ],"total":1,"limit":50,"offset":0} + """) + let client = GlanceStubURLProtocol.makeClient() + + let entries = try await client.glanceActivity() + + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries.first?.serverName, "jira") + XCTAssertEqual(entries.first?.toolName, "get_issue") + XCTAssertEqual(entries.first?.errorMessage, "auth failed") + } + ``` + +- [ ] **Step 9: Drop the `glanceActivity` shim, then run and watch the tests fail.** + + The shim left by Step 0 would otherwise return `[]` without issuing a request, so remove that line from `MCPProxy/API/UsageStub.swift` first — the file is then down to `activeSessions` alone: + + ```swift + import Foundation + + extension APIClient { + func activeSessions(limit: Int = 25) async throws -> [MCPSession] { _ = limit; return [] } + } + ``` + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter APIClientGlanceTests 2>&1 | grep -E "error:" | sort -u + ``` + + Expected output (again the main target fails first, so the test-target errors are not reached): + + ``` + …/MCPProxy/Core/CoreProcessManager.swift:875: error: value of type 'APIClient' has no member 'glanceActivity' + error: fatalError + ``` + + On a clean checkout without the spike wiring, you instead get `no member 'glanceActivity'` at the two `APIClientGlanceTests.swift` call sites. + +- [ ] **Step 10: Add `glanceActivity(limit:)` to `APIClient`.** + + In `native/macos/MCPProxy/MCPProxy/API/APIClient.swift`, insert immediately **before** the `usageAggregate` method added in Step 5: + + ```swift + /// Fetch the tray glance activity feed from `GET /api/v1/activity`. + /// + /// Separate from `recentActivity(limit:)` on purpose: this one carries the + /// tool-call `type` filter, while `recentActivity` feeds the native Dashboard, + /// which renders the FULL log (security scans, quarantine changes, OAuth). + /// The page is deliberately oversized — management built-ins are filtered + /// client-side, so a small page could be filled entirely by proxy admin calls. + func glanceActivity(limit: Int = 50) async throws -> [ActivityEntry] { + let response: ActivityListResponse = try await fetchWrapped( + path: "/api/v1/activity?type=tool_call,internal_tool_call&limit=\(limit)" + ) + return response.activities + } + + ``` + + The comma-separated `type` value is parsed server-side by `strings.Split(typeStr, ",")` in `internal/httpapi/activity.go:26-27`; a comma is a legal unescaped sub-delimiter in a URL query, and `URL.absoluteString` preserves it verbatim (asserted by the test in Step 8). + +- [ ] **Step 11: Run and watch all five tests pass.** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter APIClientGlanceTests 2>&1 | grep -E "Executed [0-9]+ tests" + ``` + + Expected output: + + ``` + Executed 5 tests, with 0 failures (0 unexpected) in 0.003 (0.003) seconds + Executed 5 tests, with 0 failures (0 unexpected) in 0.003 (0.003) seconds + Executed 5 tests, with 0 failures (0 unexpected) in 0.003 (0.004) seconds + ``` + + (SwiftPM prints the tally three times — once per test suite level: class, bundle, "Selected tests".) + +- [ ] **Step 12: Commit the glance-activity call.** + + ```bash + cd /Users/user/repos/mcpproxy-go && \ + git add native/macos/MCPProxy/MCPProxy/API/APIClient.swift \ + native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift && \ + git commit -m "feat(tray): add type-filtered glance activity API call + + GET /api/v1/activity?type=tool_call,internal_tool_call&limit=50 is a + separate feed from recentActivity(), which the native Dashboard uses to + render the full log. The 50-record page is oversized because management + built-ins are filtered client-side." + ``` + +- [ ] **Step 13: Write the failing active-sessions and `last_activity` tests.** + + Append inside the `APIClientGlanceTests` class, after `testGlanceActivityDecodesEntries`: + + ```swift + + // MARK: - Active sessions + + func testActiveSessionsRequestsStatusActive() async throws { + GlanceStubURLProtocol.responseBody = GlanceStubURLProtocol.envelope(""" + {"sessions":[],"total":0,"limit":25} + """) + let client = GlanceStubURLProtocol.makeClient() + + _ = try await client.activeSessions() + + XCTAssertEqual( + GlanceStubURLProtocol.requestedURLs, + ["http://127.0.0.1:8080/api/v1/sessions?status=active&limit=25"] + ) + } + + /// Regression: the model decoded `last_active`, but the API emits + /// `last_activity`, so every session's timestamp silently arrived as nil. + func testMCPSessionDecodesLastActivity() throws { + let json = Data(""" + {"id":"sess-1","client_name":"Claude Code","status":"active", + "tool_call_count":8,"start_time":"2026-07-29T12:00:00Z", + "last_activity":"2026-07-29T13:04:05Z"} + """.utf8) + + let session = try JSONDecoder().decode(APIClient.MCPSession.self, from: json) + + XCTAssertEqual(session.lastActivity, "2026-07-29T13:04:05Z") + XCTAssertEqual(session.clientName, "Claude Code") + XCTAssertEqual(session.toolCallCount, 8) + } + ``` + +- [ ] **Step 14: Delete the scratch placeholder, then run and watch the session tests fail.** + + The last shim goes now — with `activeSessions` removed the file holds nothing but an empty extension, so delete it outright: + + ```bash + cd /Users/user/repos/mcpproxy-go && rm -f native/macos/MCPProxy/MCPProxy/API/UsageStub.swift + ``` + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter APIClientGlanceTests 2>&1 | grep -E "error:" | sort -u + ``` + + Expected output: + + ``` + …/MCPProxy/Core/CoreProcessManager.swift:888: error: value of type 'APIClient' has no member 'activeSessions' + error: fatalError + ``` + + On a clean checkout without the spike wiring, you instead get `no member 'activeSessions'` and `value of type 'APIClient.MCPSession' has no member 'lastActivity'` from `APIClientGlanceTests.swift`. + +- [ ] **Step 15: Fix the `MCPSession` field name and its five call sites.** + + In `native/macos/MCPProxy/MCPProxy/API/APIClient.swift`, replace line 341: + + ```swift + let lastActive: String? + ``` + + with: + + ```swift + /// Timestamp of the session's most recent activity. The API field is + /// `last_activity` (Go `contracts.MCPSession.LastActivity`); decoding + /// `last_active` silently produced nil for every session. + let lastActivity: String? + ``` + + and replace line 353: + + ```swift + case lastActive = "last_active" + ``` + + with: + + ```swift + case lastActivity = "last_activity" + ``` + + Then update the only consumer — `native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift` has exactly five `.lastActive` references (lines 481, 482, 494, 495, 553): + + ```bash + cd /Users/user/repos/mcpproxy-go && \ + perl -pi -e 's/\.lastActive\b/.lastActivity/g' native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift && \ + grep -n "lastActivity" native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift + ``` + + Expected output: + + ``` + 481: let existingTime = existing.lastActivity ?? existing.startTime ?? "" + 482: let newTime = s.lastActivity ?? s.startTime ?? "" + 494: let lTime = lhs.lastActivity ?? lhs.startTime ?? "" + 495: let rTime = rhs.lastActivity ?? rhs.startTime ?? "" + 553: Text(sessionRelativeTime(session.lastActivity ?? session.startTime)) + ``` + + This is a behavioural fix beyond the tray: `DashboardView` sorts and labels "Recent Sessions" by this field, and it was comparing empty strings for every session. + +- [ ] **Step 16: Add `activeSessions(limit:)` to `APIClient`.** + + In `native/macos/MCPProxy/MCPProxy/API/APIClient.swift`, insert immediately **after** the closing brace of `sessions(limit:)` (currently line 368) and before the `// MARK: - Activity` line (currently 370): + + ```swift + + /// Fetch only currently-active MCP sessions, for the tray glance "Clients" rows. + /// + /// The `status` filter is applied server-side during the storage cursor walk, + /// before truncation — a client-side filter over a page would miss a session + /// that started long ago but is calling tools right now. + func activeSessions(limit: Int = 25) async throws -> [MCPSession] { + let response: SessionsResponse = try await fetchWrapped( + path: "/api/v1/sessions?status=active&limit=\(limit)" + ) + return response.sessions + } + ``` + + With this in place the scratch placeholder is fully superseded and the spike wiring in `CoreProcessManager.swift` compiles against the real methods again. + +- [ ] **Step 17: Run the whole native suite and watch it stay green.** + + The full suite is needed here, not just the filter: the rename in Step 15 touches `DashboardView.swift`, and `ModelsTests.swift` / `ServerBrowseModelsTests.swift` decode adjacent models. + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test 2>&1 | tail -5 + ``` + + Expected output — the baseline on the current working tree is 322 tests, so 322 + the 7 glance tests written so far = 329. The total moves as sibling tasks land; what matters is `0 failures`: + + ``` + Test Suite 'MCPProxyPackageTests.xctest' passed at 2026-07-29 15:24:07.881. + Executed 329 tests, with 0 failures (0 unexpected) in 0.083 (0.094) seconds + Test Suite 'All tests' passed at 2026-07-29 15:24:07.881. + Executed 329 tests, with 0 failures (0 unexpected) in 0.083 (0.095) seconds + ✔ Test run with 0 tests in 0 suites passed after 0.001 seconds. + ``` + +- [ ] **Step 18: Commit the sessions call and the field-name fix.** + + ```bash + cd /Users/user/repos/mcpproxy-go && \ + git add native/macos/MCPProxy/MCPProxy/API/APIClient.swift \ + native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift \ + native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift && \ + git commit -m "fix(tray): decode last_activity, add active-session API call + + MCPSession declared last_active, but the API emits last_activity, so the + field was nil for every session — DashboardView was sorting and labelling + Recent Sessions off an always-empty string. + + Adds activeSessions(limit:), which asks the server for status=active + rather than filtering a truncated page client-side." + ``` + +- [ ] **Step 19: Write the counting stub and the two failing seam tests.** + + Create `native/macos/MCPProxy/MCPProxyTests/CountingGlanceDataSource.swift`: + + ```swift + // CountingGlanceDataSource.swift + // MCPProxyTests + // + // A GlanceDataSource that performs no I/O and counts calls. Used to pin the + // spec-048 invariant: building the tray menu issues zero requests. + + import Foundation + @testable import MCPProxy + + final class CountingGlanceDataSource: GlanceDataSource { + + private(set) var usageCallCount = 0 + private(set) var activityCallCount = 0 + private(set) var sessionCallCount = 0 + + /// Total requests this data source was asked to make. + var totalCallCount: Int { usageCallCount + activityCallCount + sessionCallCount } + + var usageToReturn = UsageAggregateResponse( + window: "24h", + tokenSource: "bytes", + tokensSaved: 0, + tokensSavedPercentage: 0, + timeline: [] + ) + var activityToReturn: [ActivityEntry] = [] + var sessionsToReturn: [APIClient.MCPSession] = [] + + func usageAggregate(window: String, top: Int) async throws -> UsageAggregateResponse { + usageCallCount += 1 + return usageToReturn + } + + func glanceActivity(limit: Int) async throws -> [ActivityEntry] { + activityCallCount += 1 + return activityToReturn + } + + func activeSessions(limit: Int) async throws -> [APIClient.MCPSession] { + sessionCallCount += 1 + return sessionsToReturn + } + } + ``` + + Then append inside the `APIClientGlanceTests` class, after `testMCPSessionDecodesLastActivity`: + + ```swift + + // MARK: - Data-source seam + + func testAPIClientConformsToGlanceDataSource() async throws { + GlanceStubURLProtocol.responseBody = GlanceStubURLProtocol.envelope(""" + {"sessions":[],"total":0,"limit":25} + """) + let source: any GlanceDataSource = GlanceStubURLProtocol.makeClient() + + _ = try await source.activeSessions(limit: 25) + + XCTAssertEqual(GlanceStubURLProtocol.requestedURLs.count, 1) + } + + func testCountingStubSatisfiesTheProtocolAndIssuesNoRequests() async throws { + let stub = CountingGlanceDataSource() + let source: any GlanceDataSource = stub + + _ = try await source.usageAggregate(window: "24h", top: 1) + _ = try await source.glanceActivity(limit: 50) + _ = try await source.activeSessions(limit: 25) + + XCTAssertEqual(stub.totalCallCount, 3) + XCTAssertTrue(GlanceStubURLProtocol.requestedURLs.isEmpty) + } + ``` + +- [ ] **Step 20: Run and watch the seam tests fail.** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter APIClientGlanceTests 2>&1 | grep -E "error:" | sort -u + ``` + + Expected output — the main target is healthy at this point, so these are genuine test-target errors: + + ``` + …/MCPProxyTests/APIClientGlanceTests.swift:138:25: error: cannot find type 'GlanceDataSource' in scope + …/MCPProxyTests/APIClientGlanceTests.swift:147:25: error: cannot find type 'GlanceDataSource' in scope + …/MCPProxyTests/CountingGlanceDataSource.swift:10:39: error: cannot find type 'GlanceDataSource' in scope + error: emit-module command failed with exit code 1 (use -v to see invocation) + error: fatalError + ``` + +- [ ] **Step 21: Create the `GlanceDataSource` protocol.** + + Create `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceDataSource.swift` (the `Menu/Glance/` directory already exists; `Package.swift` globs it automatically): + + ```swift + // GlanceDataSource.swift + // MCPProxy + // + // The narrow read surface the tray glance section depends on. + + import Foundation + + /// The three reads that feed the tray glance section. + /// + /// The glance component depends on this protocol rather than on the concrete + /// `APIClient` actor so a counting stub can be injected in tests. That is what + /// makes the spec-048 invariant testable: opening the menu must perform zero + /// network requests, which can only be asserted if the requests are countable. + protocol GlanceDataSource { + /// `GET /api/v1/activity/usage?window=&top=` + func usageAggregate(window: String, top: Int) async throws -> UsageAggregateResponse + + /// `GET /api/v1/activity?type=tool_call,internal_tool_call&limit=` + func glanceActivity(limit: Int) async throws -> [ActivityEntry] + + /// `GET /api/v1/sessions?status=active&limit=` + func activeSessions(limit: Int) async throws -> [APIClient.MCPSession] + } + + /// The production data source. `APIClient` already has all three methods with + /// matching signatures, so the conformance is declaration-only. + extension APIClient: GlanceDataSource {} + ``` + + Two Swift details worth knowing: an `actor`'s isolated methods satisfy `async` protocol requirements (SE-0306), which is why the empty extension is enough; and default argument values (`window: String = "24h"`) do not change a method's signature, so they do not break the match. + +- [ ] **Step 22: Run the whole native suite and watch everything pass.** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test 2>&1 | grep -E "APIClientGlanceTests|Executed [0-9]+ tests" | tail -14 + ``` + + Expected output — 9 glance tests, and 322 + 9 = 331 overall on the current working tree. As in Step 17 the grand total moves with sibling tasks; `0 failures` and the 9 named cases are what this step pins. XCTest orders methods by ASCII, so `testAPIClient…` precedes `testActiveSessions…`: + + ``` + Test Suite 'APIClientGlanceTests' started at 2026-07-29 15:22:13.114. + Test Case '-[MCPProxyTests.APIClientGlanceTests testAPIClientConformsToGlanceDataSource]' passed (0.000 seconds). + Test Case '-[MCPProxyTests.APIClientGlanceTests testActiveSessionsRequestsStatusActive]' passed (0.006 seconds). + Test Case '-[MCPProxyTests.APIClientGlanceTests testCountingStubSatisfiesTheProtocolAndIssuesNoRequests]' passed (0.000 seconds). + Test Case '-[MCPProxyTests.APIClientGlanceTests testGlanceActivityDecodesEntries]' passed (0.000 seconds). + Test Case '-[MCPProxyTests.APIClientGlanceTests testGlanceActivityRequestsToolCallTypesWithOversizedPage]' passed (0.000 seconds). + Test Case '-[MCPProxyTests.APIClientGlanceTests testMCPSessionDecodesLastActivity]' passed (0.000 seconds). + Test Case '-[MCPProxyTests.APIClientGlanceTests testUsageAggregateDecodesTimelineBuckets]' passed (0.002 seconds). + Test Case '-[MCPProxyTests.APIClientGlanceTests testUsageAggregateRequestsWindowAndTop]' passed (0.000 seconds). + Test Case '-[MCPProxyTests.APIClientGlanceTests testUsageBucketDecodesFractionalSecondTimestamps]' passed (0.000 seconds). + Executed 9 tests, with 0 failures (0 unexpected) in 0.010 (0.010) seconds + Executed 331 tests, with 0 failures (0 unexpected) in 0.084 (0.095) seconds + Executed 331 tests, with 0 failures (0 unexpected) in 0.084 (0.100) seconds + ``` + +- [ ] **Step 23: Commit the data-source protocol.** + + ```bash + cd /Users/user/repos/mcpproxy-go && \ + git add native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceDataSource.swift \ + native/macos/MCPProxy/MCPProxyTests/CountingGlanceDataSource.swift \ + native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift && \ + git commit -m "refactor(tray): extract GlanceDataSource protocol over APIClient + + The glance section depends on a three-method protocol instead of the + concrete APIClient actor, so a counting stub can be injected and the + spec-048 invariant (menu open issues zero requests) becomes assertable. + + APIClient's conformance is declaration-only — an actor's isolated methods + already satisfy async protocol requirements." + ``` + +--- + +### Task 3: Swift — AppState glance fields, refresh wiring, and disconnect reset + +**Files:** +- Create: `native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift` +- Modify: `native/macos/MCPProxy/MCPProxy/State/AppState.swift` — line 37 (the `coreState` declaration), insert a new block immediately before line 75 (`// MARK: Token metrics (from status response)`), and append new members immediately before the closing brace at line 260 +- Modify: `native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift` — lines 751–760 (`refreshState()`), and insert three new methods immediately before line 866 (`/// Fetch token metrics from the status endpoint and update appState.`) +- Test: `native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift` + +> Line numbers are from `main` at commit `b2642a57` and have been verified against that tree. If Task 4 (the SSE adapter) has already landed its edits to `CoreProcessManager.swift`, the `refreshState()` body and the token-metrics comment will have moved — locate them by the quoted text, not by number. The anchors in `AppState.swift` are untouched by any other task. + +**Interfaces:** + +*Consumes (produced by Task 2, all in `native/macos/MCPProxy/MCPProxy/API/`):* +```swift +// Models.swift — Codable conformance MUST live in an extension, so the memberwise +// initialiser below IS synthesised and usable from tests. (Declaring init(from:) +// inside the struct body suppresses it and breaks this task's test helper — see +// the recognition note in Step 2.) +struct UsageBucket: Equatable { ... } +extension UsageBucket: Codable { ... } + +struct UsageBucket: Codable, Equatable { + let start: Date // UTC-hour aligned + let calls: Int // INCLUDES errors + let errors: Int + let totalRespBytes: Int +} +init(start: Date, calls: Int, errors: Int, totalRespBytes: Int) // memberwise + +struct UsageAggregateResponse: Codable, Equatable { + let window: String + let tokenSource: String? + let tokensSaved: Int? + let tokensSavedPercentage: Double? + let timeline: [UsageBucket] // never nil; empty when idle +} + +// APIClient.swift — `actor APIClient`, so every call needs `await`. +func glanceActivity(limit: Int = 50) async throws -> [ActivityEntry] +func activeSessions(limit: Int = 25) async throws -> [MCPSession] // = APIClient.MCPSession +func usageAggregate(window: String = "24h", top: Int = 1) async throws -> UsageAggregateResponse +``` + +*Consumes (already on `main`, verified by reading the files at `b2642a57`):* +```swift +// State/AppState.swift +final class AppState: ObservableObject // NOT @MainActor at class level (line 32) +@Published var recentActivity: [ActivityEntry] = [] // line 64 +@Published var recentSessions: [APIClient.MCPSession] = [] // line 65 +@MainActor func updateActivity(_ entries: [ActivityEntry]) // line 245 +@MainActor func transition(to newState: CoreState) // line 257 +// Core/CoreState.swift +enum CoreState: Equatable { case idle, launching, waitingForCore, connected, + reconnecting(attempt: Int), error(CoreError), shuttingDown } +enum CoreError: Error, Equatable { ... case general(String) ... } +// API/Models.swift +struct ActivityEntry: Codable, Identifiable, Equatable // id/type/status/timestamp are non-optional +// NOTE (line 570): `==` compares `id` ONLY. Array equality on [ActivityEntry] therefore +// cannot detect a changed status or a late-arriving hasSensitiveData flag. +``` + +*Produces (Task 5's `GlanceSection` and Task 4's SSE adapter depend on these exact symbols):* +```swift +// on AppState +@Published var glanceActivity: [ActivityEntry] // default [] +@Published var glanceSessions: [APIClient.MCPSession] // default [] +@Published var usageTimeline: [UsageBucket]? // nil = not loaded +@Published var callsThisHour: Int? // nil = not loaded +static func floorToHour(_ date: Date) -> Date +static func callsInCurrentHour(_ timeline: [UsageBucket], now: Date = Date()) -> Int +@MainActor func updateGlanceActivity(_ entries: [ActivityEntry]) +@MainActor func updateGlanceSessions(_ sessions: [APIClient.MCPSession]) +@MainActor func updateUsage(timeline: [UsageBucket], now: Date = Date()) +func clearGlanceState() // nonisolated, callable from didSet +// invariant: setting `coreState` to anything other than `.connected` calls clearGlanceState() +``` + +--- + +- [ ] **Step 1: Create the failing test file with the two current-hour tests** + + Create `native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift` with exactly this content. (SwiftPM globs the `MCPProxyTests/` directory — `Package.swift:18-22` declares the test target with `path: "MCPProxyTests"`, so no target registration is needed for a new test file.) + + ```swift + import XCTest + @testable import MCPProxy + + /// Tray Glance: the four `AppState` glance fields, the current-UTC-hour + /// derivation behind `callsThisHour`, the disconnect reset, and the guarantee + /// that the shared Dashboard/ActivityView feeds are not narrowed. + @MainActor + final class AppStateGlanceTests: XCTestCase { + + // MARK: - callsThisHour + + /// A sparse timeline whose newest bucket is three hours old must report + /// zero calls this hour, NOT that bucket's count. Buckets are UTC-hour + /// aligned and the endpoint omits hours with no activity, so "the most + /// recent bucket" is a count from the past presented as if it were current. + func testCallsThisHourIsZeroWhenCurrentHourBucketIsAbsent() throws { + let now = Self.date("2026-07-29T14:05:00Z") + let timeline = [ + Self.bucket("2026-07-29T10:00:00Z", calls: 7), + Self.bucket("2026-07-29T11:00:00Z", calls: 12, errors: 2), + ] + + let state = AppState() + state.coreState = .connected + state.updateUsage(timeline: timeline, now: now) + + XCTAssertEqual(state.callsThisHour, 0) + XCTAssertEqual(state.usageTimeline?.count, 2) + } + + /// When the current UTC hour does have a bucket, its `calls` is the headline + /// number — regardless of where it sits in the array. + func testCallsThisHourReadsTheCurrentHourBucket() throws { + let now = Self.date("2026-07-29T11:42:31Z") + let timeline = [ + Self.bucket("2026-07-29T11:00:00Z", calls: 12, errors: 2), + Self.bucket("2026-07-29T10:00:00Z", calls: 7), + ] + + let state = AppState() + state.updateUsage(timeline: timeline, now: now) + + XCTAssertEqual(state.callsThisHour, 12) + } + + // MARK: - Helpers + + private static func date(_ iso: String) -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + // swiftlint:disable:next force_unwrapping + return formatter.date(from: iso)! + } + + private static func bucket(_ iso: String, calls: Int, errors: Int = 0) -> UsageBucket { + UsageBucket(start: date(iso), calls: calls, errors: errors, totalRespBytes: 0) + } + + private static func activity(id: String, type: String, status: String = "success") throws -> ActivityEntry { + let json = """ + {"id":"\(id)","type":"\(type)","status":"\(status)","timestamp":"2026-07-29T11:00:00Z"} + """ + // swiftlint:disable:next force_unwrapping + return try JSONDecoder().decode(ActivityEntry.self, from: json.data(using: .utf8)!) + } + + private static func session(id: String, status: String) throws -> APIClient.MCPSession { + let json = """ + {"id":"\(id)","client_name":"Claude Code","status":"\(status)","tool_call_count":3} + """ + // swiftlint:disable:next force_unwrapping + return try JSONDecoder().decode(APIClient.MCPSession.self, from: json.data(using: .utf8)!) + } + } + ``` + + The `activity(id:type:status:)` and `session(id:status:)` helpers are unused until Step 4; Swift does not warn on unused private static methods, so this compiles cleanly once the AppState members exist. Both decode from a minimal JSON body on purpose: `ActivityEntry` has exactly four non-optional fields (`id`, `type`, `status`, `timestamp`) and `MCPSession` two (`id`, `status`), so no fixture drift when Task 2 or 4 touches those models. + +- [ ] **Step 2: Run the test and watch it fail to compile** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter AppStateGlanceTests 2>&1 | tail -20 + ``` + + Expected: the build fails before any test runs, with errors on lines 25, 27 and 28 of `AppStateGlanceTests.swift` reading + + ``` + error: value of type 'AppState' has no member 'updateUsage' + error: value of type 'AppState' has no member 'callsThisHour' + error: value of type 'AppState' has no member 'usageTimeline' + ``` + + and a trailing `error: fatalError`. Zero tests executed. (Match on the message text; do not gate on the exact column numbers swiftc reports.) + + Two failure modes that are **not** this task's bug — recognise them and fix them where they belong: + - `cannot find 'UsageBucket' in scope` → Task 2 has not landed. Stop and complete Task 2 first. + - `missing argument for parameter 'from' in call` on the `Self.bucket` helper → Task 2 declared `init(from decoder:)` **inside** the `UsageBucket` struct body, which suppresses the synthesised memberwise initialiser. Move Task 2's Codable conformance into an `extension UsageBucket: Codable { … }`, per its interface contract. Do not work around it by rewriting this test to decode fixtures. + +- [ ] **Step 3: Add the four glance fields and the usage derivation to AppState** + + In `native/macos/MCPProxy/MCPProxy/State/AppState.swift`, insert this block immediately **before** the line ` // MARK: Token metrics (from status response)` (line 75 on a clean tree): + + ```swift + // MARK: Tray Glance feeds (separate from the shared recentActivity/recentSessions) + + /// Tool-call activity for the tray glance section, fetched with a `type` + /// filter. Deliberately NOT the same feed as `recentActivity`: the native + /// Dashboard renders the full activity log (security scans, quarantine + /// changes, OAuth events) from that one, so narrowing it would gut the view. + @Published var glanceActivity: [ActivityEntry] = [] + + /// Active-only MCP sessions for the tray glance "Clients" rows. Separate from + /// `recentSessions`, which ActivityView and DashboardView use to resolve + /// session ids to client names and therefore must keep closed sessions. + @Published var glanceSessions: [APIClient.MCPSession] = [] + + /// Hourly call timeline for the last 24h. `nil` means "not loaded yet"; + /// an empty array means "loaded, and the proxy was idle". + @Published var usageTimeline: [UsageBucket]? + + /// Calls recorded in the CURRENT UTC hour. `nil` means "not loaded yet". + @Published var callsThisHour: Int? + + ``` + + Then insert this block immediately **after** the closing brace of `func transition(to newState: CoreState)` (line 259) and **before** the final `}` that closes `final class AppState` (line 260 on a clean tree): + + ```swift + + // MARK: Tray Glance helpers + + /// Truncate a date to the start of its UTC hour. Unix time is UTC by + /// definition, so flooring the epoch seconds needs no Calendar or TimeZone. + static func floorToHour(_ date: Date) -> Date { + let seconds = date.timeIntervalSince1970 + return Date(timeIntervalSince1970: (seconds / 3600).rounded(.down) * 3600) + } + + /// Calls recorded in the UTC hour containing `now`. + /// + /// Buckets are UTC-hour aligned and SPARSE — the endpoint omits hours with no + /// activity. Picking "the newest bucket" would therefore show a count from + /// hours ago as if it were current, so this matches on the bucket start and + /// returns 0 when the current hour has no bucket. + static func callsInCurrentHour(_ timeline: [UsageBucket], now: Date = Date()) -> Int { + let currentHour = floorToHour(now) + for bucket in timeline where floorToHour(bucket.start) == currentHour { + return bucket.calls + } + return 0 + } + + /// Store the 24h timeline and derive the current-hour headline count. + /// + /// Both assignments are guarded. This file's rule (see `updateServers`) is + /// "only publish when the data actually differs", because every `@Published` + /// write feeds the debounced `objectWillChange → rebuildMenu()` sink in + /// MCPProxyApp — an unguarded write here would rebuild the menu every 30s on + /// a completely idle proxy. `UsageBucket` is `Equatable`, so the guard is free. + @MainActor + func updateUsage(timeline: [UsageBucket], now: Date = Date()) { + if usageTimeline != timeline { usageTimeline = timeline } + let calls = AppState.callsInCurrentHour(timeline, now: now) + if callsThisHour != calls { callsThisHour = calls } + } + ``` + +- [ ] **Step 4: Run the test and watch it pass** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter AppStateGlanceTests 2>&1 | tail -8 + ``` + + Expected tail: + + ``` + Test Case '-[MCPProxyTests.AppStateGlanceTests testCallsThisHourIsZeroWhenCurrentHourBucketIsAbsent]' passed (0.002 seconds). + Test Case '-[MCPProxyTests.AppStateGlanceTests testCallsThisHourReadsTheCurrentHourBucket]' passed (0.000 seconds). + Test Suite 'AppStateGlanceTests' passed at ... + Executed 2 tests, with 0 failures (0 unexpected) in 0.003 (0.004) seconds + ``` + +- [ ] **Step 5: Commit the fields and the derivation** + + ```bash + cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/State/AppState.swift native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift && git commit -m "feat(tray): AppState glance fields + current-UTC-hour usage derivation + +Adds glanceActivity/glanceSessions/usageTimeline/callsThisHour alongside the +shared recentActivity/recentSessions feeds, which stay broad because the native +Dashboard renders the full activity log and ActivityView resolves session ids +through recentSessions. + +callsThisHour matches the bucket whose start equals the current UTC hour and +reports 0 when that hour is absent; usage buckets are sparse, so taking the +newest bucket would present an hours-old count as live." + ``` + +- [ ] **Step 6: Add the failing tests for the disconnect reset, the reconciling poll, and the shared-feed guarantee** + + In `native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift`, insert this block immediately **before** the line ` // MARK: - Helpers`: + + ```swift + /// "Loaded, and the proxy was idle" must be expressible: an empty timeline + /// yields 0, which is deliberately distinct from the nil loading state. + func testEmptyTimelineYieldsZeroNotNil() { + let state = AppState() + XCTAssertNil(state.callsThisHour, "callsThisHour starts nil = not loaded yet") + + state.updateUsage(timeline: [], now: Self.date("2026-07-29T11:42:00Z")) + + XCTAssertEqual(state.callsThisHour, 0) + XCTAssertEqual(state.usageTimeline, []) + } + + // MARK: - Disconnect reset + + /// The connect path flips to `.connected` before the first refresh + /// completes, so glance state from a previous core must be cleared the + /// moment the core state leaves `.connected`. + func testGlanceStateClearedOnDisconnect() throws { + let state = AppState() + state.coreState = .connected + state.updateGlanceActivity([try Self.activity(id: "a1", type: "tool_call")]) + state.updateGlanceSessions([try Self.session(id: "s1", status: "active")]) + state.updateUsage( + timeline: [Self.bucket("2026-07-29T11:00:00Z", calls: 12)], + now: Self.date("2026-07-29T11:10:00Z") + ) + + state.coreState = .idle + + XCTAssertTrue(state.glanceActivity.isEmpty) + XCTAssertTrue(state.glanceSessions.isEmpty) + XCTAssertNil(state.usageTimeline) + XCTAssertNil(state.callsThisHour) + } + + /// Every non-connected state clears, not just `.idle` — a reconnecting or + /// errored core must not keep showing the old numbers either. + func testGlanceStateClearedOnReconnectingAndError() throws { + for target in [CoreState.reconnecting(attempt: 1), .error(.general("boom")), .shuttingDown] { + let state = AppState() + state.coreState = .connected + state.updateGlanceActivity([try Self.activity(id: "a1", type: "tool_call")]) + state.callsThisHour = 9 + + state.coreState = target + + XCTAssertTrue(state.glanceActivity.isEmpty, "\(target) should clear glanceActivity") + XCTAssertNil(state.callsThisHour, "\(target) should clear callsThisHour") + } + } + + /// Staying connected must not wipe the feeds the refresh loop just filled. + func testConnectedStateDoesNotClearGlanceState() throws { + let state = AppState() + state.coreState = .connected + state.updateGlanceActivity([try Self.activity(id: "a1", type: "tool_call")]) + state.callsThisHour = 4 + + state.coreState = .connected + + XCTAssertEqual(state.glanceActivity.map(\.id), ["a1"]) + XCTAssertEqual(state.callsThisHour, 4) + } + + // MARK: - Reconciling poll + + /// The 30s poll exists to reconcile the SSE-fed optimistic list with the + /// server's canonical records — including the asynchronously-computed + /// sensitive-data flag and the final status, which arrive on a record whose + /// id has NOT changed. `ActivityEntry`'s Equatable is id-only + /// (API/Models.swift:570), so an id-only guard would drop those corrections + /// forever and the row would lie until it scrolled off. + func testGlanceActivityUpdatesWhenOnlyStatusChanges() throws { + let state = AppState() + state.updateGlanceActivity([try Self.activity(id: "a1", type: "tool_call")]) + state.updateGlanceActivity([try Self.activity(id: "a1", type: "tool_call", status: "error")]) + + XCTAssertEqual(state.glanceActivity.first?.status, "error") + } + + // MARK: - Shared feeds are not narrowed + + /// Dashboard non-regression: `recentActivity` still carries non-tool-call + /// records (security scans, OAuth events) and `recentSessions` still carries + /// closed sessions after the glance feeds are populated alongside them. + func testGlanceFeedsDoNotNarrowSharedFeeds() throws { + let state = AppState() + state.coreState = .connected + + state.updateActivity([ + try Self.activity(id: "a1", type: "tool_call"), + try Self.activity(id: "a2", type: "security_scan"), + ]) + state.recentSessions = [ + try Self.session(id: "s1", status: "active"), + try Self.session(id: "s2", status: "closed"), + ] + + state.updateGlanceActivity([try Self.activity(id: "a1", type: "tool_call")]) + state.updateGlanceSessions([try Self.session(id: "s1", status: "active")]) + + XCTAssertEqual(state.recentActivity.map(\.type), ["tool_call", "security_scan"]) + XCTAssertEqual(state.recentSessions.map(\.status), ["active", "closed"]) + XCTAssertEqual(state.glanceActivity.map(\.id), ["a1"]) + XCTAssertEqual(state.glanceSessions.map(\.id), ["s1"]) + } + + ``` + +- [ ] **Step 7: Run and watch the new tests fail to compile** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter AppStateGlanceTests 2>&1 | tail -20 + ``` + + Expected: compilation fails with errors reading + + ``` + error: value of type 'AppState' has no member 'updateGlanceActivity' + error: value of type 'AppState' has no member 'updateGlanceSessions' + ``` + + and a trailing `error: fatalError`. Zero tests executed. (`updateUsage`, `callsThisHour` and `updateActivity` all resolve by now — only the two new helpers are missing.) + +- [ ] **Step 8: Add the update helpers, `clearGlanceState()`, and the `coreState` didSet** + + In `native/macos/MCPProxy/MCPProxy/State/AppState.swift`, first replace the `coreState` declaration (line 37 on a clean tree, currently ` @Published var coreState: CoreState = .idle`) — keep the existing `/// Current core process state (uses CoreState from CoreState.swift).` comment line above it and make the whole thing read: + + ```swift + /// Current core process state (uses CoreState from CoreState.swift). + /// + /// Tray Glance: any state other than `.connected` clears the glance feeds. + /// The connect path flips to `.connected` BEFORE the first refresh completes + /// (CoreProcessManager.launchAndConnect), so without this reset the menu + /// would briefly present the previous core's numbers as live. The reset lives + /// in `didSet` rather than in `transition(to:)` because two call sites assign + /// `coreState` directly (CoreProcessManager.awaitExternalCore, + /// MCPProxyApp.stopCore) and would otherwise bypass it. + @Published var coreState: CoreState = .idle { + didSet { + if coreState != .connected { clearGlanceState() } + } + } + ``` + + Then append these three members to the `// MARK: Tray Glance helpers` section added in Step 3, immediately after `updateUsage(timeline:now:)` and before the class's final `}`: + + ```swift + + /// Replace the glance activity feed. Leaves `recentActivity` untouched. + /// + /// `ActivityEntry`'s Equatable is id-only (API/Models.swift:570), so guarding + /// on ids alone would drop the reconciling poll's late corrections: the + /// sensitive-data flag is computed asynchronously and the final status + /// arrives on a record whose id has not changed. Fingerprint the fields the + /// glance rows actually render instead — still cheap, still churn-free. + @MainActor + func updateGlanceActivity(_ entries: [ActivityEntry]) { + func fingerprint(_ list: [ActivityEntry]) -> [String] { + list.map { "\($0.id)|\($0.status)|\($0.hasSensitiveData == true)" } + } + if fingerprint(entries) != fingerprint(glanceActivity) { + glanceActivity = entries + } + } + + /// Replace the glance (active-only) session feed. Leaves `recentSessions` untouched. + @MainActor + func updateGlanceSessions(_ sessions: [APIClient.MCPSession]) { + if sessions.map(\.id) != glanceSessions.map(\.id) { + glanceSessions = sessions + } + } + + /// Drop every glance feed. Called from `coreState.didSet` on any state other + /// than `.connected` so a stopped or reconnecting core never shows the + /// previous core's numbers as live. + /// + /// Deliberately NOT `@MainActor`: a property observer is a nonisolated + /// context, so an isolated method could not be called from it without + /// `await`. `AppState` itself is not `@MainActor` either, so a plain method + /// is already nonisolated. Every real assignment to `coreState` happens on + /// the main actor anyway — `transition(to:)`, plus the two `MainActor.run` + /// blocks in CoreProcessManager.awaitExternalCore and MCPProxyApp.stopCore. + func clearGlanceState() { + if !glanceActivity.isEmpty { glanceActivity = [] } + if !glanceSessions.isEmpty { glanceSessions = [] } + if usageTimeline != nil { usageTimeline = nil } + if callsThisHour != nil { callsThisHour = nil } + } + ``` + +- [ ] **Step 9: Run the full glance test class and watch all eight pass** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter AppStateGlanceTests 2>&1 | tail -14 + ``` + + Expected tail: + + ``` + Test Case '-[MCPProxyTests.AppStateGlanceTests testGlanceActivityUpdatesWhenOnlyStatusChanges]' passed (0.000 seconds). + Test Case '-[MCPProxyTests.AppStateGlanceTests testGlanceFeedsDoNotNarrowSharedFeeds]' passed (0.000 seconds). + Test Case '-[MCPProxyTests.AppStateGlanceTests testGlanceStateClearedOnDisconnect]' passed (0.000 seconds). + Test Case '-[MCPProxyTests.AppStateGlanceTests testGlanceStateClearedOnReconnectingAndError]' passed (0.000 seconds). + Test Suite 'AppStateGlanceTests' passed at ... + Executed 8 tests, with 0 failures (0 unexpected) in 0.004 (0.004) seconds + ``` + +- [ ] **Step 10: Confirm the pre-existing AppState tests still pass under the new didSet** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter AppStateStatusSummaryTests 2>&1 | grep Executed | tail -1 && swift test --filter AppStateOAuthSignInTests 2>&1 | grep Executed | tail -1 + ``` + + Expected: `Executed 4 tests, with 0 failures` for `AppStateStatusSummaryTests` and `Executed 8 tests, with 0 failures` for `AppStateOAuthSignInTests` (exact counts may differ if those files grew; the requirement is `0 failures` in both). Note `grep Executed` rather than `tail -4`: `swift test --filter` prints the swift-testing runner's summary last, which would otherwise hide the XCTest counts. + +- [ ] **Step 11: Commit the disconnect reset** + + ```bash + cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/State/AppState.swift native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift && git commit -m "feat(tray): clear glance state whenever the core leaves .connected + +The connect path transitions to .connected before the first refresh completes, +so hiding the glance block is not enough — without a reset the menu would +briefly render the previous core's activity, clients and call count as live. + +The reset hangs off coreState's didSet rather than transition(to:) because +CoreProcessManager.awaitExternalCore and MCPProxyApp.stopCore assign coreState +directly and would otherwise bypass it. + +updateGlanceActivity fingerprints id+status+hasSensitiveData rather than id +alone: ActivityEntry's Equatable is id-only, so an id guard would discard the +reconciling poll's late status and sensitive-data corrections." + ``` + +- [ ] **Step 12: Wire the three glance fetches into the 30-second refresh loop** + + This step has no test of its own: `refreshState()` is `private` on `actor CoreProcessManager` and the package carries no URLSession stub or fake `APIClient`, so there is nothing to assert against without inventing a mock layer no other task uses. It is verified structurally in Step 13 (build + call-site grep) and behaviourally by the AppState tests above. + + In `native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift`, replace the body of `refreshState()` (lines 751–760 on a clean tree) so it reads: + + ```swift + private func refreshState() async { + await refreshActivity() + await refreshSessions() + await refreshGlanceActivity() + await refreshGlanceSessions() + await refreshUsage() + await refreshTokenMetrics() + await refreshSecurityStatus() + await refreshProfiles() + // Bump activityVersion so ActivityView reloads + // (SSE doesn't emit "activity" events, so periodic refresh is needed) + await MainActor.run { appState.activityVersion += 1 } + } + ``` + + Then insert these three methods immediately **before** the line ` /// Fetch token metrics from the status endpoint and update appState.` (line 866 on a clean tree): + + ```swift + /// Tray Glance: fetch the type-filtered tool-call feed for the menu's + /// "Recent" rows. Separate from `refreshActivity()` on purpose — that feed + /// stays broad because the native Dashboard renders the full activity log. + private func refreshGlanceActivity() async { + guard let apiClient else { return } + do { + let entries = try await apiClient.glanceActivity(limit: 50) + await appState.updateGlanceActivity(entries) + } catch { + // Non-fatal; we'll retry on the next refresh + } + } + + /// Tray Glance: fetch active-only sessions for the menu's "Clients" rows. + /// Separate from `refreshSessions()`, which must keep closed sessions so + /// ActivityView can resolve session ids to client names. + private func refreshGlanceSessions() async { + guard let apiClient else { return } + do { + let sessions = try await apiClient.activeSessions(limit: 25) + await appState.updateGlanceSessions(sessions) + } catch { + // Non-fatal; we'll retry on the next refresh + } + } + + /// Tray Glance: fetch the 24h usage aggregate that backs both the header + /// count and the histogram submenu. + private func refreshUsage() async { + guard let apiClient else { return } + do { + let usage = try await apiClient.usageAggregate(window: "24h", top: 1) + await appState.updateUsage(timeline: usage.timeline) + } catch { + // Non-fatal; the header and histogram stay in their loading state + } + } + + ``` + + All three ride `refreshState()`, which already runs on connect (`attachToExternalCore` :302, `launchAndConnect` :334), on `attemptReconnection` (:933), on the `config.reloaded` SSE event (:677), and every 30 s from `startPeriodicRefresh()` (:742) — no new timer is introduced, and no fetch is added to the menu-open path. + +- [ ] **Step 13: Build and verify the wiring is present** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift build 2>&1 | tail -3 && grep -c "await refreshGlanceActivity()\|await refreshGlanceSessions()\|await refreshUsage()" MCPProxy/Core/CoreProcessManager.swift + ``` + + Expected: the `swift build` output ends with a `Build complete!` line (the preceding `[n/m]` progress lines vary with how much is already cached — an incremental build prints as little as `[0/3] Write swift-version-…`), followed by + + ``` + 3 + ``` + + A count other than `3` means the calls were not added to `refreshState()`. The three method *definitions* do not match the pattern (`private func refreshGlanceActivity() async` has no `await`), so `3` is exactly the three call sites. If the build reports `value of type 'APIClient' has no member 'glanceActivity'` or `... 'usageAggregate'`, Task 2's APIClient methods are missing — land Task 2 first. + +- [ ] **Step 14: Run the whole Swift suite exactly as CI does** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test 2>&1 | grep -E "Executed [0-9]+ tests|error:" | tail -4 + ``` + + Expected: a final `Executed N tests, with 0 failures (0 unexpected)` line for `MCPProxyPackageTests.xctest`. Running without the grep also prints the swift-testing runner's `✔ Test run with 0 tests in 0 suites passed` afterwards — no swift-testing tests exist in this package, so that line is normal. Any failure here must be fixed before committing: `.github/workflows/native-tests.yml:83` runs bare `swift test` with an explicit "do not reintroduce `--skip`" comment. + +- [ ] **Step 15: Commit the refresh wiring** + + ```bash + cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift && git commit -m "feat(tray): fetch glance activity, active sessions and 24h usage on the refresh loop + +Three additional fetches on the existing 30s CoreProcessManager cycle, each +failing soft like the surrounding refreshers. No new timer and no menu-open +network call: the tray menu still renders entirely from AppState (spec 048)." + ``` + +--- + +### Task 4: Swift — SSE activity adapter (live glance rows, no refetch) + +The core emits `activity.tool_call.completed` and `activity.internal_tool_call.completed` (`internal/runtime/events.go:30,42`); it has never emitted a bare `activity` event, so the `case "activity":` branch in the tray's SSE switch is dead code and activity is 30 s-polled today. This task replaces that branch with a real handler that adapts the event payload into an `ActivityEntry` and prepends it to the glance feed — **without** calling `refreshActivity()`, which would fire one REST GET per event. + +Three payload facts drive the adapter, all verified against the Go source: + +- The wire format is an envelope `{"payload": {...}, "timestamp": 1753800000}` where `timestamp` is **Unix seconds** (`internal/httpapi/server.go:3205-3208`), while `ActivityEntry.timestamp` is an ISO-8601 **string** (`native/macos/MCPProxy/MCPProxy/API/Models.swift:548`). +- Upstream events carry `server_name` / `tool_name` (`internal/runtime/event_bus.go:444-454`); internal events carry `internal_tool_name` plus `target_server` / `target_tool` **only when non-empty** (`internal/runtime/event_bus.go:552-565`). Reading `tool_name` for an internal event yields a row with no tool at all. +- The failure field is `error_message`, not `error` (`event_bus.go:451`, `event_bus.go:557`) — matching `ActivityEntry.errorMessage`. + +The provisional id is `request_id + ":" + type`. A bare `request_id` is not safe: a failed upstream call emits **both** events under one request id, and `ActivityEntry` derives `Identifiable`/`Equatable` from `id` alone (`Models.swift:537,570-572`), so the two distinct records would collide in the feed before `GlanceSelection`'s rule 4 ever got to choose between them. + +**Files:** +- Create: `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceEvent.swift` — the `Menu/Glance/` directory already exists (`GlanceFormatting.swift`, `GlanceSelection.swift` live there) +- Test: `native/macos/MCPProxy/MCPProxyTests/GlanceEventTests.swift` +- Modify: `native/macos/MCPProxy/MCPProxy/State/AppState.swift` — insert the prepend helper immediately after `updateGlanceActivity(_:)` (added by the AppState-fields task) +- Modify: `native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift:683-700` — replace the whole `case "activity":` block inside `handleSSEEvent(_:)` + +No `Package.swift` edit is needed: both targets glob by path (`.executableTarget(name: "MCPProxy", path: "MCPProxy")`, `.testTarget(name: "MCPProxyTests", path: "MCPProxyTests")`), so a new file in any subdirectory is picked up automatically. + +**Interfaces:** + +*Consumes* (from earlier tasks / existing code): +- `AppState.glanceActivity` — `@Published var glanceActivity: [ActivityEntry] = []` (AppState glance-fields task) +- `AppState.updateGlanceActivity(_ entries: [ActivityEntry])` — `@MainActor`, the reconciling poll's wholesale replace (AppState glance-fields task); this task's helper sits next to it +- `ActivityEntry` — existing struct, `native/macos/MCPProxy/MCPProxy/API/Models.swift:536-572`; it declares no `init`, so Swift synthesises the internal memberwise initialiser used below, in declaration order: `id, type, source, serverName, toolName, arguments, response, responseTruncated, status, errorMessage, durationMs, timestamp, sessionId, requestId, metadata, hasSensitiveData, detectionTypes, maxSeverity` +- `SSEEvent` — existing struct, `Models.swift:748`; `event: String` is the SSE event name, `data: String` is the raw JSON +- `GlanceSelection.qualifies(_:)` / `.activityRows(from:limit:)` and `GlanceFormatting.parseTimestamp(_:)` / `.relativeTime(_:now:)` — existing, `Menu/Glance/`; the adapter's output must survive both, so the tests assert against them rather than re-implementing their logic + +*Produces* (later tasks may rely on these exact symbols): +- `enum GlanceEvent` with `static let upstreamCompleted = "activity.tool_call.completed"`, `static let internalCompleted = "activity.internal_tool_call.completed"`, and `static func adapt(eventName: String, data: Data) -> ActivityEntry?` +- `AppState.glanceActivityCap: Int` (`static let`, value `50`) +- `AppState.prependGlanceActivity(_ entry: ActivityEntry)` — `@MainActor` +- A `case GlanceEvent.upstreamCompleted, GlanceEvent.internalCompleted:` branch in `CoreProcessManager.handleSSEEvent(_:)` that issues zero REST requests **and bumps no counter that other views refetch on** + +--- + +- [ ] **Step 1: Write the failing adapter test file** + + Create `native/macos/MCPProxyTests/GlanceEventTests.swift` — full path `native/macos/MCPProxy/MCPProxyTests/GlanceEventTests.swift` — with exactly this content. The suite is XCTest (`import XCTest` + `@testable import MCPProxy` in every existing test file; there is no swift-testing usage in the package). `@MainActor` on the class matches `AppStateStatusSummaryTests.swift` and is required by Step 6, which touches `AppState`. + + ```swift + import XCTest + @testable import MCPProxy + + /// Tray Glance — SSE activity adapter. + /// + /// The core emits `activity.tool_call.completed` / + /// `activity.internal_tool_call.completed`, never `activity`, and the payload is + /// an envelope `{payload, timestamp}` whose timestamp is Unix SECONDS while + /// `ActivityEntry.timestamp` is an ISO-8601 string. These tests pin the mapping, + /// and assert it against the real consumers (`GlanceSelection`, + /// `GlanceFormatting`) rather than a locally rebuilt copy of their logic. + @MainActor + final class GlanceEventTests: XCTestCase { + + /// Upstream calls carry `server_name` / `tool_name` (event_bus.go + /// EmitActivityToolCallCompleted, payload literal at :444-454). + func testUpstreamCompletedPayloadBecomesToolCallEntry() throws { + let json = """ + {"payload":{"server_name":"github","tool_name":"create_issue", + "session_id":"sess-1","request_id":"req-1","source":"mcp","status":"success", + "error_message":"","duration_ms":142},"timestamp":1753800000} + """ + let entry = try XCTUnwrap(GlanceEvent.adapt( + eventName: "activity.tool_call.completed", + data: Data(json.utf8) + )) + + XCTAssertEqual(entry.type, "tool_call") + XCTAssertEqual(entry.serverName, "github") + XCTAssertEqual(entry.toolName, "create_issue") + XCTAssertEqual(entry.status, "success") + XCTAssertEqual(entry.durationMs, 142) + XCTAssertEqual(entry.sessionId, "sess-1") + XCTAssertEqual(entry.requestId, "req-1") + XCTAssertNil(entry.errorMessage, "empty error_message must not become a failure detail") + } + + /// Internal calls carry `internal_tool_name`, and `target_server` only when + /// non-empty (event_bus.go EmitActivityInternalToolCall, :552-565). Reading + /// `tool_name` here would produce a row with no tool at all — and the row + /// must survive the selection rules and the relative-time formatter, not + /// merely hold the right field values. + func testInternalCompletedPayloadUsesInternalToolNameAndTargetServer() throws { + let json = """ + {"payload":{"internal_tool_name":"call_tool_read","target_server":"jira", + "target_tool":"get_issue","session_id":"sess-2","request_id":"req-2", + "status":"error","error_message":"auth failed","duration_ms":9}, + "timestamp":1753800000} + """ + let entry = try XCTUnwrap(GlanceEvent.adapt( + eventName: "activity.internal_tool_call.completed", + data: Data(json.utf8) + )) + + XCTAssertEqual(entry.type, "internal_tool_call") + XCTAssertEqual(entry.toolName, "call_tool_read") + XCTAssertEqual(entry.serverName, "jira") + XCTAssertEqual(entry.status, "error") + XCTAssertTrue(GlanceSelection.qualifies(entry), + "a failed wrapper qualifies under rule 3") + XCTAssertEqual(GlanceFormatting.relativeTime( + entry.timestamp, + now: Date(timeIntervalSince1970: 1_753_800_012) + ), "12s", "the tray's own parser must accept the timestamp we emit") + } + + /// `target_server` is omitted for discovery built-ins such as + /// retrieve_tools, so the row must tolerate its absence. + func testInternalPayloadWithoutTargetServerHasNilServerName() throws { + let json = """ + {"payload":{"internal_tool_name":"retrieve_tools","session_id":"sess-3", + "request_id":"req-3","status":"success","duration_ms":4}, + "timestamp":1753800000} + """ + let entry = try XCTUnwrap(GlanceEvent.adapt( + eventName: "activity.internal_tool_call.completed", + data: Data(json.utf8) + )) + + XCTAssertEqual(entry.toolName, "retrieve_tools") + XCTAssertNil(entry.serverName) + } + + /// The core does not persist started events (activity_service.go), so a row + /// built from one would never be reconciled by the poll. + func testStartedEventIsIgnored() { + let json = """ + {"payload":{"server_name":"github","tool_name":"create_issue", + "request_id":"req-4"},"timestamp":1753800000} + """ + XCTAssertNil(GlanceEvent.adapt( + eventName: "activity.tool_call.started", + data: Data(json.utf8) + )) + } + + /// A failed upstream call emits BOTH events under ONE request id, and + /// `ActivityEntry` derives identity and equality from `id` alone — so a bare + /// request id would make the two records collide before rule 4 could pick + /// the `tool_call` one. The last assertion is the point of the composite id. + func testPairedEventsUnderOneRequestIdGetDistinctIds() throws { + let upstream = """ + {"payload":{"server_name":"jira","tool_name":"get_issue", + "request_id":"req-5","status":"error","error_message":"auth failed"}, + "timestamp":1753800000} + """ + let wrapper = """ + {"payload":{"internal_tool_name":"call_tool_read","target_server":"jira", + "request_id":"req-5","status":"error","error_message":"auth failed"}, + "timestamp":1753800000} + """ + let a = try XCTUnwrap(GlanceEvent.adapt( + eventName: "activity.tool_call.completed", data: Data(upstream.utf8))) + let b = try XCTUnwrap(GlanceEvent.adapt( + eventName: "activity.internal_tool_call.completed", data: Data(wrapper.utf8))) + + XCTAssertEqual(a.id, "req-5:tool_call") + XCTAssertEqual(b.id, "req-5:internal_tool_call") + XCTAssertNotEqual(a, b) + XCTAssertEqual(a.requestId, b.requestId, "the shared request id is what rule 4 collapses on") + XCTAssertEqual(GlanceSelection.activityRows(from: [a, b]).map(\.id), + ["req-5:tool_call"], + "rule 4 keeps the record that names the real server:tool") + } + + /// The payload key is `error_message`, matching `ActivityEntry` — a read + /// keyed on `error` would render a failed call as if it had no detail. + func testFailureDetailComesFromErrorMessageKey() throws { + let json = """ + {"payload":{"server_name":"jira","tool_name":"get_issue","request_id":"req-6", + "status":"error","error_message":"auth failed: token expired"}, + "timestamp":1753800000} + """ + let entry = try XCTUnwrap(GlanceEvent.adapt( + eventName: "activity.tool_call.completed", + data: Data(json.utf8) + )) + + XCTAssertEqual(entry.errorMessage, "auth failed: token expired") + } + + /// The envelope timestamp is Unix seconds; the entry must carry a string the + /// tray's OWN parser accepts. Rebuilding an ISO8601DateFormatter here would + /// let the test pass while GlanceFormatting rejected the string. + func testEnvelopeUnixSecondsBecomeParsableISO8601() throws { + let json = """ + {"payload":{"server_name":"github","tool_name":"create_issue", + "request_id":"req-7","status":"success"},"timestamp":1753800000} + """ + let entry = try XCTUnwrap(GlanceEvent.adapt( + eventName: "activity.tool_call.completed", + data: Data(json.utf8) + )) + + let parsed = try XCTUnwrap(GlanceFormatting.parseTimestamp(entry.timestamp)) + XCTAssertEqual(parsed.timeIntervalSince1970, 1_753_800_000, accuracy: 0.001) + } + } + ``` + +- [ ] **Step 2: Run the test and watch it fail to build** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceEventTests + ``` + + Expected — the build fails because the type does not exist yet. SwiftPM prints one `error:` line per reference plus a caret continuation line, then its terse summary: + + ``` + /Users/user/repos/mcpproxy-go/native/macos/MCPProxy/MCPProxyTests/GlanceEventTests.swift:19:...: error: cannot find 'GlanceEvent' in scope + | `- error: cannot find 'GlanceEvent' in scope + ... + error: fatalError + ``` + +- [ ] **Step 3: Create the adapter** + + Create `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceEvent.swift` (the directory already exists alongside `GlanceFormatting.swift` and `GlanceSelection.swift`) with exactly this content: + + ```swift + // GlanceEvent.swift + // MCPProxy + // + // Adapts `activity.*.completed` SSE payloads into `ActivityEntry` values so the + // tray glance section can render live rows without issuing a REST request per + // event. + + import Foundation + + /// Maps runtime activity SSE payloads onto `ActivityEntry`. + enum GlanceEvent { + /// SSE event name for a completed upstream tool call. + static let upstreamCompleted = "activity.tool_call.completed" + + /// SSE event name for a completed internal (built-in) tool call. + static let internalCompleted = "activity.internal_tool_call.completed" + + /// Build an `ActivityEntry` from an SSE envelope. + /// + /// Returns nil for any other event name (notably + /// `activity.tool_call.started`) and for a payload that does not parse. + static func adapt(eventName: String, data: Data) -> ActivityEntry? { + let type: String + switch eventName { + case upstreamCompleted: type = "tool_call" + case internalCompleted: type = "internal_tool_call" + default: return nil + } + + guard let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let payload = envelope["payload"] as? [String: Any] else { return nil } + + let toolName: String? + let serverName: String? + if type == "tool_call" { + toolName = nonEmptyString(payload["tool_name"]) + serverName = nonEmptyString(payload["server_name"]) + } else { + toolName = nonEmptyString(payload["internal_tool_name"]) + serverName = nonEmptyString(payload["target_server"]) + } + + let requestId = nonEmptyString(payload["request_id"]) + let provisionalId = requestId.map { "\($0):\(type)" } ?? "sse-\(UUID().uuidString):\(type)" + + let seconds = (envelope["timestamp"] as? NSNumber)?.doubleValue + ?? Date().timeIntervalSince1970 + let timestamp = isoFormatter.string(from: Date(timeIntervalSince1970: seconds)) + + return ActivityEntry( + id: provisionalId, + type: type, + source: nonEmptyString(payload["source"]), + serverName: serverName, + toolName: toolName, + arguments: nil, + response: nil, + responseTruncated: nil, + status: nonEmptyString(payload["status"]) ?? "success", + errorMessage: nonEmptyString(payload["error_message"]), + durationMs: (payload["duration_ms"] as? NSNumber)?.int64Value, + timestamp: timestamp, + sessionId: nonEmptyString(payload["session_id"]), + requestId: requestId, + metadata: nil, + hasSensitiveData: nil, + detectionTypes: nil, + maxSeverity: nil + ) + } + + private static func nonEmptyString(_ value: Any?) -> String? { + guard let text = value as? String, !text.isEmpty else { return nil } + return text + } + + private static let isoFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + } + ``` + + Notes on three choices that are not arbitrary. `(payload["duration_ms"] as? NSNumber)?.int64Value` is used because `JSONSerialization` produces `NSNumber`, and a direct `as? Int64` bridge is not guaranteed for every numeric representation. `status` defaults to `"success"` when the key is missing so a malformed payload cannot masquerade as a failed call (`GlanceSelection` rule 3 treats "status is not success" as an inclusion signal). The formatter emits fractional seconds, which `GlanceFormatting.parseTimestamp` accepts on its first attempt (it falls back to a non-fractional parser for polled records) — that pairing is what Step 1's last test pins. + +- [ ] **Step 4: Run the test and watch it pass** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceEventTests + ``` + + Expected tail: + + ``` + Test Case '-[MCPProxyTests.GlanceEventTests testUpstreamCompletedPayloadBecomesToolCallEntry]' passed (0.000 seconds). + Test Suite 'GlanceEventTests' passed at ... + Executed 7 tests, with 0 failures (0 unexpected) in 0.003 (0.003) seconds + ``` + +- [ ] **Step 5: Commit the adapter** + + ```bash + cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceEvent.swift native/macos/MCPProxy/MCPProxyTests/GlanceEventTests.swift && git commit -m "feat(tray): adapt activity SSE payloads into ActivityEntry rows" + ``` + +- [ ] **Step 6: Write the failing prepend/cap test** + + Append this method to `native/macos/MCPProxy/MCPProxyTests/GlanceEventTests.swift`, immediately after `testEnvelopeUnixSecondsBecomeParsableISO8601()` and before the closing `}` of the class: + + ```swift + /// SSE rows go in newest-first and the feed is bounded, so a busy agent + /// cannot grow `glanceActivity` without limit between reconciling polls. + func testPrependPutsNewestFirstAndCapsTheFeed() throws { + let state = AppState() + state.coreState = .connected + + for index in 0..<(AppState.glanceActivityCap + 5) { + let json = """ + {"payload":{"server_name":"github","tool_name":"create_issue", + "request_id":"req-\(index)","status":"success"},"timestamp":1753800000} + """ + let entry = try XCTUnwrap(GlanceEvent.adapt( + eventName: "activity.tool_call.completed", + data: Data(json.utf8) + )) + state.prependGlanceActivity(entry) + } + + XCTAssertEqual(state.glanceActivity.count, AppState.glanceActivityCap) + XCTAssertEqual(state.glanceActivity.first?.requestId, "req-\(AppState.glanceActivityCap + 4)") + XCTAssertTrue(state.recentActivity.isEmpty, "the shared Dashboard feed must not be touched") + } + ``` + + `state.coreState = .connected` is required: the AppState glance-fields task installs a `didSet` on `coreState` that calls `clearGlanceState()` while the core is not connected, so a default-constructed `AppState` (which starts at `.idle`) would wipe the feed on each prepend. + +- [ ] **Step 7: Run the test and watch it fail to build** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceEventTests + ``` + + Expected (two distinct diagnostics, each with a caret continuation line; line numbers depend on where the method landed): + + ``` + .../MCPProxyTests/GlanceEventTests.swift:...: error: type 'AppState' has no member 'glanceActivityCap' + .../MCPProxyTests/GlanceEventTests.swift:...: error: value of type 'AppState' has no member 'prependGlanceActivity' + error: fatalError + ``` + +- [ ] **Step 8: Add the bounded prepend helper to AppState** + + In `native/macos/MCPProxy/MCPProxy/State/AppState.swift`, insert the following immediately after the closing `}` of `updateGlanceActivity(_:)` — i.e. directly above the doc comment `/// Replace the glance (active-only) session feed.` (anchor on that comment rather than a line number; sibling tasks are editing this file concurrently): + + ```swift + /// Upper bound on rows kept in `glanceActivity`. Matches the page size the + /// reconciling poll requests (`apiClient.glanceActivity(limit: 50)`), so SSE + /// rows and polled rows agree on depth. + static let glanceActivityCap = 50 + + /// Prepend one optimistic row adapted from an SSE payload (newest first). + /// Bounded so a busy agent cannot grow the feed without limit; the 30s + /// reconciling poll replaces the list wholesale with canonical records. + @MainActor + func prependGlanceActivity(_ entry: ActivityEntry) { + glanceActivity.insert(entry, at: 0) + if glanceActivity.count > AppState.glanceActivityCap { + glanceActivity.removeLast(glanceActivity.count - AppState.glanceActivityCap) + } + } + ``` + + This deliberately does **not** use the id-list equality guard that `updateGlanceSessions(_:)` still carries (`if sessions.map(\.id) != glanceSessions.map(\.id)`): a prepend is by definition a change, and that guard exists only to stop redundant `@Published` churn on identical poll results. + +- [ ] **Step 9: Run the test and watch it pass** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceEventTests + ``` + + Expected tail: + + ``` + Test Case '-[MCPProxyTests.GlanceEventTests testPrependPutsNewestFirstAndCapsTheFeed]' passed (0.001 seconds). + Test Suite 'GlanceEventTests' passed at ... + Executed 8 tests, with 0 failures (0 unexpected) in 0.004 (0.004) seconds + ``` + +- [ ] **Step 10: Commit the prepend helper** + + Stage only this task's hunks — sibling tasks have uncommitted edits in `AppState.swift`, and a blanket `git add` of the file would sweep them into this commit: + + ```bash + cd /Users/user/repos/mcpproxy-go && git add -p native/macos/MCPProxy/MCPProxy/State/AppState.swift && git add native/macos/MCPProxy/MCPProxyTests/GlanceEventTests.swift && git commit -m "feat(tray): bounded prepend for the glance activity feed" + ``` + +- [ ] **Step 11: Replace the dead SSE case in CoreProcessManager** + + In `native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift`, inside `handleSSEEvent(_:)` (the `switch event.event` at line 626; this case occupies lines 683-700), delete this entire block: + + ```swift + case "activity": + // New activity; refresh and check for sensitive data + let oldSensitive = await MainActor.run { appState.sensitiveDataAlertCount } + await refreshActivity() + await MainActor.run { appState.activityVersion += 1 } + let newSensitive = await MainActor.run { appState.sensitiveDataAlertCount } + // Notify on new sensitive data detections + if newSensitive > oldSensitive { + if let latest = await MainActor.run(body: { + appState.recentActivity.first(where: { $0.hasSensitiveData == true }) + }) { + await notificationService.sendSensitiveDataAlert( + server: latest.serverName ?? "unknown", + tool: latest.toolName ?? "unknown", + category: "sensitive data" + ) + } + } + ``` + + Note what this deletion does and does not cost: it removes the only `sendSensitiveDataAlert` call site in `handleSSEEvent`. No behaviour is lost, because the core has never emitted a bare `activity` event, so the branch — and therefore the alert — has never fired. (Sensitive-data notification on a real event stream is out of scope here; the core's own `sensitive_data.detected` event is unhandled by the tray both before and after this change.) `notificationService` stays in use from the `servers.changed` case, so no unused-property warning appears. + + Put this in its place (same indentation — it is a `case` of the same `switch`): + + ```swift + case GlanceEvent.upstreamCompleted, GlanceEvent.internalCompleted: + // Tray Glance: adapt the payload into a row and prepend it. + // Deliberately NO refreshActivity() here — a REST GET per event is + // network amplification, not push. The 30s reconciling poll + // (refreshGlanceActivity) replaces these optimistic rows with the + // storage-assigned records. `activity.tool_call.started` is ignored: + // the core does not persist started events, so a row built from one + // would never be reconciled. `activityVersion` is deliberately NOT + // bumped either — ActivityView reloads on that counter + // (ActivityView.swift:174 → loadSummary + loadActivities), so a + // per-event bump is the same amplification through a second door + // whenever the Activity window is open. + guard let data = event.data.data(using: .utf8), + let entry = GlanceEvent.adapt(eventName: event.event, data: data) else { + break + } + await appState.prependGlanceActivity(entry) + ``` + + `case GlanceEvent.upstreamCompleted, ...` matches on `static let` string constants — legal in a Swift `switch` over `String` (expression-pattern `~=`) and it keeps the event names in one place. `await appState.prependGlanceActivity(entry)` hops to the main actor exactly like the existing `await appState.updateServers(servers)` call at line 654. + +- [ ] **Step 12: Verify no amplification and no dead case remain** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && \ + grep -n 'case "activity":' MCPProxy/Core/CoreProcessManager.swift; \ + grep -c 'await refreshActivity()' MCPProxy/Core/CoreProcessManager.swift; \ + grep -c 'activityVersion' MCPProxy/Core/CoreProcessManager.swift + ``` + + Expected: the first `grep` prints nothing (exit status 1 — the dead case is gone); the second prints `1`; the third prints `2`. + + Counts, not line numbers, are the assertion here: line numbers in this file shift as sibling tasks land. One `refreshActivity()` means the only remaining call is the one in `refreshState()` (the periodic/refresh path), and two `activityVersion` hits mean the `config.reloaded` bump and the `refreshState()` bump — neither of them on the SSE tool-call path. If the third count is 3, the `activityVersion += 1` line was left in the new case and every completed tool call will trigger `loadSummary()` + `loadActivities()` in an open Activity window. + +- [ ] **Step 13: Run the whole Swift suite (what CI runs)** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test + ``` + + Baseline before this task is 323 tests, so expect 331 (the total grows further as sibling tasks land; the invariants are zero failures and the 8 `GlanceEventTests` cases among them): + + ``` + Executed 8 tests, with 0 failures (0 unexpected) in 0.004 (0.004) seconds + Executed 331 tests, with 0 failures (0 unexpected) in 0.086 (0.103) seconds + ✔ Test run with 0 tests in 0 suites passed after 0.001 seconds. + ``` + + (The trailing swift-testing line is normal — this package has no swift-testing tests, only XCTest.) + +- [ ] **Step 14: Commit the SSE handler** + + ```bash + cd /Users/user/repos/mcpproxy-go && git add -p native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift && git commit -m "fix(tray): consume activity.*.completed SSE events without refetching" + ``` + + `-p` again: the AppState-fields/API-client tasks also touch this file (`refreshGlanceActivity`, `refreshGlanceSessions`, `refreshUsage`), and those hunks belong to their own commits. + +--- + +### Task 5: Swift — `GlanceSelection` and `GlanceFormatting` (pure logic, TDD) + +The tray glance section's display rules and text formatting, as pure Swift functions with no AppKit and no networking. This is where the bulk of the design's logic tests live. Everything here is synchronous, value-in/value-out, and independently runnable via `swift test`. + +**Files:** + +- Create: `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceFormatting.swift` +- Create: `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift` +- Test (create): `native/macos/MCPProxy/MCPProxyTests/GlanceFormattingTests.swift` +- Test (create): `native/macos/MCPProxy/MCPProxyTests/GlanceSelectionTests.swift` +- Test (create): `native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift` +- Read-only (do not edit): `native/macos/MCPProxy/MCPProxy/API/Models.swift` lines 536–572 (`ActivityEntry`), `native/macos/MCPProxy/MCPProxy/API/APIClient.swift` lines 330–355 (`APIClient.MCPSession`) + +No `Package.swift` change is needed. `native/macos/MCPProxy/Package.swift` declares both targets with `path:`-based globbing (`path: "MCPProxy"` and `path: "MCPProxyTests"`), which recurses into subdirectories, so dropping files into `MCPProxy/Menu/Glance/` and `MCPProxyTests/` is enough for `swift test` to pick them up. + +**Interfaces:** + +*Consumes* (all already exist on `main`; this task adds no dependency on any other task in this plan): + +- `struct ActivityEntry: Codable, Identifiable, Equatable` — `native/macos/MCPProxy/MCPProxy/API/Models.swift:536`. Fields read here: `id: String`, `type: String`, `serverName: String?` (`server_name`), `toolName: String?` (`tool_name`), `status: String`, `requestId: String?` (`request_id`), `timestamp: String` (RFC3339). Note `==` compares `id` only (`Models.swift:570`), which is why the tests below assert on `.id` and never on whole values. +- `struct APIClient.MCPSession: Codable, Identifiable` — `native/macos/MCPProxy/MCPProxy/API/APIClient.swift:331`. Fields read here: `id: String`, `status: String`. It is *not* `Equatable`, so tests compare `sessions.map(\.id)`. + +*Produces* (later tasks — the `GlanceSection` menu builder and the SSE `GlanceEvent` adapter — call exactly these): + +```swift +enum GlanceSelection { + static let managementBuiltIns: Set // ["upstream_servers", "quarantine_security"] + static let glanceInternalTools: Set // ["retrieve_tools", "code_execution", "describe_tool"] + static let rowLimit: Int // 5 + static func qualifies(_ entry: ActivityEntry) -> Bool + static func collapseByRequestID(_ entries: [ActivityEntry]) -> [ActivityEntry] + static func activityRows(from entries: [ActivityEntry], limit: Int = rowLimit) -> [ActivityEntry] + static func activeClients(from sessions: [APIClient.MCPSession], limit: Int = rowLimit) -> [APIClient.MCPSession] +} + +enum GlanceFormatting { + static func statusSymbolName(for entry: ActivityEntry) -> String + static func rowLabel(for entry: ActivityEntry) -> String + static func middleTruncated(_ text: String, limit: Int) -> String + static func parseTimestamp(_ timestamp: String) -> Date? + static func compactAge(_ seconds: TimeInterval) -> String + static func relativeTime(_ timestamp: String, now: Date = Date()) -> String +} +``` + +Also produced, for reuse by sibling test files in the same target: the static fixture factories `GlanceSelectionTests.entry(id:type:server:tool:status:requestId:) -> ActivityEntry` and `GlanceSelectionTests.session(id:status:clientName:) -> APIClient.MCPSession`. + +--- + +- [ ] **Step 1: Create the Glance directory and the failing formatting test** + + Run this first so the directory exists: + + ```bash + mkdir -p /Users/user/repos/mcpproxy-go/native/macos/MCPProxy/MCPProxy/Menu/Glance + ``` + + Then create `native/macos/MCPProxy/MCPProxyTests/GlanceFormattingTests.swift` with exactly this content: + + ```swift + import XCTest + @testable import MCPProxy + + final class GlanceFormattingTests: XCTestCase { + + // MARK: - Status symbol + + func testStatusSymbolDistinguishesSuccessErrorAndOther() { + XCTAssertEqual(GlanceFormatting.statusSymbolName(for: Self.entry(status: "success")), "checkmark.circle") + XCTAssertEqual(GlanceFormatting.statusSymbolName(for: Self.entry(status: "error")), "xmark.circle") + XCTAssertEqual(GlanceFormatting.statusSymbolName(for: Self.entry(status: "blocked")), "exclamationmark.circle") + } + + // MARK: - Row label + + func testUpstreamCallLabelIsServerColonTool() { + let entry = Self.entry(type: "tool_call", server: "github", tool: "create_issue") + XCTAssertEqual(GlanceFormatting.rowLabel(for: entry), "github:create_issue") + } + + func testBuiltInLabelIsJustTheToolName() { + let entry = Self.entry(type: "internal_tool_call", tool: "retrieve_tools") + XCTAssertEqual(GlanceFormatting.rowLabel(for: entry), "retrieve_tools") + } + + func testAlreadyPrefixedToolNameIsNotDoubled() { + let entry = Self.entry(type: "tool_call", server: "github", tool: "github:create_issue") + XCTAssertEqual(GlanceFormatting.rowLabel(for: entry), "github:create_issue") + } + + func testLabelFallsBackToTypeWhenNothingIsNamed() { + let entry = Self.entry(type: "oauth_event") + XCTAssertEqual(GlanceFormatting.rowLabel(for: entry), "oauth_event") + } + + // MARK: - Truncation + + func testShortTextIsNotTruncated() { + XCTAssertEqual(GlanceFormatting.middleTruncated("github:create", limit: 20), "github:create") + } + + func testMiddleTruncationKeepsHeadAndTailAtExactlyTheLimit() { + let result = GlanceFormatting.middleTruncated("github:create_issue_from_template", limit: 12) + XCTAssertEqual(result.count, 12) + XCTAssertTrue(result.hasPrefix("github"), "kept the server prefix, got \(result)") + XCTAssertTrue(result.hasSuffix("late"), "kept the tool tail, got \(result)") + XCTAssertTrue(result.contains("\u{2026}")) + } + + func testTruncationToOneCharacterIsJustTheEllipsis() { + XCTAssertEqual(GlanceFormatting.middleTruncated("abcdef", limit: 1), "\u{2026}") + } + + // MARK: - Relative time + + func testCompactAgeUnits() { + XCTAssertEqual(GlanceFormatting.compactAge(0), "0s") + XCTAssertEqual(GlanceFormatting.compactAge(12), "12s") + XCTAssertEqual(GlanceFormatting.compactAge(59), "59s") + XCTAssertEqual(GlanceFormatting.compactAge(60), "1m") + XCTAssertEqual(GlanceFormatting.compactAge(3599), "59m") + XCTAssertEqual(GlanceFormatting.compactAge(3600), "1h") + XCTAssertEqual(GlanceFormatting.compactAge(86_400), "1d") + XCTAssertEqual(GlanceFormatting.compactAge(-5), "0s") + } + + func testRelativeTimeParsesFractionalAndPlainTimestamps() { + let fractional = "2027-01-15T08:00:00.123Z" + let plain = "2027-01-15T08:00:00Z" + XCTAssertNotNil(GlanceFormatting.parseTimestamp(fractional)) + XCTAssertNotNil(GlanceFormatting.parseTimestamp(plain)) + + let base = GlanceFormatting.parseTimestamp(plain)! + XCTAssertEqual(GlanceFormatting.relativeTime(plain, now: base.addingTimeInterval(12)), "12s") + XCTAssertEqual(GlanceFormatting.relativeTime(fractional, now: base.addingTimeInterval(180)), "3m") + } + + func testUnparseableTimestampFallsBackToTheRawString() { + XCTAssertEqual(GlanceFormatting.relativeTime("not-a-date"), "not-a-date") + } + + // MARK: - Helpers + + static func entry( + id: String = "a1", + type: String = "tool_call", + server: String? = nil, + tool: String? = nil, + status: String = "success" + ) -> ActivityEntry { + var json: [String: Any] = [ + "id": id, + "type": type, + "status": status, + "timestamp": "2027-01-15T08:00:00Z" + ] + if let server { json["server_name"] = server } + if let tool { json["tool_name"] = tool } + let data = try! JSONSerialization.data(withJSONObject: json) + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(ActivityEntry.self, from: data) + } + } + ``` + + The fixture builds an `ActivityEntry` by decoding JSON through the real `Codable` path rather than calling a memberwise initialiser. That is the established convention in this test target (see `MCPProxyTests/AppStateStatusSummaryTests.swift:50-66`, the `makeServer` helper under its `// MARK: - Helpers`) and it keeps the fixtures working when fields are added to `ActivityEntry`. + + The `testRelativeTimeParsesFractionalAndPlainTimestamps` `3m` assertion depends on rounding: the fractional timestamp is 0.123 s later than `base`, so the age is 179.877 s, which `compactAge` rounds to 180 before dividing. Do not "simplify" the rounding out of the implementation in Step 3. + +- [ ] **Step 2: Run the formatting test and watch it fail to compile** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceFormattingTests + ``` + + Expected output — the build fails because the type does not exist yet (this is the RED state; in Swift a missing symbol is a compile error, not a runtime assertion failure). Swift emits one diagnostic per reference, so the same error repeats down the file: + + ``` + [52/63] Compiling MCPProxyTests GlanceFormattingTests.swift + /Users/user/repos/mcpproxy-go/native/macos/MCPProxy/MCPProxyTests/GlanceFormattingTests.swift:9:24: error: cannot find 'GlanceFormatting' in scope + /Users/user/repos/mcpproxy-go/native/macos/MCPProxy/MCPProxyTests/GlanceFormattingTests.swift:10:24: error: cannot find 'GlanceFormatting' in scope + /Users/user/repos/mcpproxy-go/native/macos/MCPProxy/MCPProxyTests/GlanceFormattingTests.swift:11:24: error: cannot find 'GlanceFormatting' in scope + ... (one per call site, through line 79) ... + error: fatalError + ``` + + The `[52/63]` build-progress index varies from run to run and with the number of files in the target — do not treat a different number as a signal. Each diagnostic is also followed by a source-context block (the surrounding lines with a caret), elided above. + + A `warning: 'mcpproxy': found 4 file(s) which are unhandled` line about `Info.plist`, `MCPProxy.entitlements`, `mcpproxy.icns` and `Assets.xcassets` is printed on every build in this package, as is a `MCPProxyApp.swift:1039` "expression is 'async' but is not marked with 'await'" warning. Both are pre-existing and unrelated — ignore them. + +- [ ] **Step 3: Implement `GlanceFormatting`** + + Create `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceFormatting.swift` with exactly this content: + + ```swift + // GlanceFormatting.swift + // MCPProxy + // + // Pure presentation helpers for the tray glance section: status symbol, + // row label, middle truncation, and compact relative age. + // Salvaged from the retired Menu/TrayMenu.swift. + + import Foundation + + /// Pure, AppKit-free formatting helpers for glance rows. + enum GlanceFormatting { + + // MARK: - Status symbol + + /// SF Symbol name for an activity record's outcome. + /// + /// Shape carries the meaning (never colour alone), so a checkmark, a + /// cross and an exclamation mark are three distinct glyphs. + static func statusSymbolName(for entry: ActivityEntry) -> String { + switch entry.status { + case "success": + return "checkmark.circle" + case "error": + return "xmark.circle" + default: + return "exclamationmark.circle" + } + } + + // MARK: - Row label + + /// Compose the row's primary label. + /// + /// Upstream calls read `server:tool`; discovery/execution built-ins read + /// just the built-in's name because they have no upstream server. + static func rowLabel(for entry: ActivityEntry) -> String { + let tool = entry.toolName ?? "" + let server = entry.serverName ?? "" + + if entry.type == "tool_call", !server.isEmpty, !tool.isEmpty { + // Guard against a tool name that already carries the prefix. + if tool.hasPrefix("\(server):") { + return tool + } + return "\(server):\(tool)" + } + if !tool.isEmpty { + return tool + } + if !server.isEmpty { + return server + } + return entry.type + } + + // MARK: - Truncation + + /// Middle-truncate `text` to at most `limit` characters, keeping the head + /// (the server prefix) and the tail (the end of the tool name). + static func middleTruncated(_ text: String, limit: Int) -> String { + guard limit > 0 else { return "" } + guard text.count > limit else { return text } + let keep = limit - 1 // one slot for the ellipsis + let head = keep / 2 + keep % 2 + let tail = keep - head + let prefix = String(text.prefix(head)) + let suffix = tail > 0 ? String(text.suffix(tail)) : "" + return prefix + "\u{2026}" + suffix + } + + // MARK: - Time + + private static let fractionalParser: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + private static let plainParser: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + /// Parse an RFC3339/ISO-8601 timestamp with or without fractional seconds. + static func parseTimestamp(_ timestamp: String) -> Date? { + if let date = fractionalParser.date(from: timestamp) { return date } + return plainParser.date(from: timestamp) + } + + /// Compact, locale-independent age string: `12s`, `3m`, `5h`, `2d`. + static func compactAge(_ seconds: TimeInterval) -> String { + let total = max(0, Int(seconds.rounded())) + if total < 60 { return "\(total)s" } + let minutes = total / 60 + if minutes < 60 { return "\(minutes)m" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)h" } + return "\(hours / 24)d" + } + + /// Relative age of an activity timestamp, falling back to the raw string + /// when it cannot be parsed. + static func relativeTime(_ timestamp: String, now: Date = Date()) -> String { + guard let date = parseTimestamp(timestamp) else { return timestamp } + return compactAge(now.timeIntervalSince(date)) + } + } + ``` + + Three deliberate departures from the salvaged `Menu/TrayMenu.swift` originals (that file is dead code — nothing in the target references `TrayMenu`; the live menu is built imperatively in `MCPProxyApp.swift`). + + First, `statusSymbolName` drops the `hasSensitiveData` branch that `TrayMenu.activityIcon(for:)` had (`Menu/TrayMenu.swift:419-429`) — sensitive-data badges on activity rows are an explicit non-goal of the design (`2026-07-29-tray-glance-design.md:176`). + + Second, `statusSymbolName` is three-way where `activityIcon` was two-way: the original returned `"checkmark.circle"` for *everything* that was not `"error"`, which would have drawn a green tick on a `blocked` or `timeout` record. The new default arm returns `"exclamationmark.circle"`, and `testStatusSymbolDistinguishesSuccessErrorAndOther`'s `"blocked"` case pins it. + + Third, `relativeTime` does **not** use `RelativeDateTimeFormatter` the way `TrayMenu.relativeTime(_:)` did (`Menu/TrayMenu.swift:446-463`): that produces locale-dependent strings like `"12 sec. ago"`, whereas the design's row layout calls for the compact right-aligned `12s` / `3m` / `5h`. `compactAge` is locale-free, which also makes it deterministically testable on any CI runner. + +- [ ] **Step 4: Run the formatting test and watch it pass** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceFormattingTests + ``` + + Expected output (tail): + + ``` + Test Suite 'GlanceFormattingTests' passed at 2026-07-29 15:19:37.808. + Executed 11 tests, with 0 failures (0 unexpected) in 0.003 (0.003) seconds + Test Suite 'MCPProxyPackageTests.xctest' passed at 2026-07-29 15:19:37.808. + Executed 11 tests, with 0 failures (0 unexpected) in 0.003 (0.003) seconds + Test Suite 'Selected tests' passed at 2026-07-29 15:19:37.808. + Executed 11 tests, with 0 failures (0 unexpected) in 0.003 (0.009) seconds + ◇ Test run started. + ↳ Testing Library Version: 1902 + ↳ Target Platform: arm64e-apple-macos14.0 + ✔ Test run with 0 tests in 0 suites passed after 0.001 seconds. + ``` + + The trailing `Test run with 0 tests in 0 suites passed` block is the swift-testing runner, which executes alongside XCTest. This package has no swift-testing tests, so zero is correct — it is not a failure. + +- [ ] **Step 5: Commit `GlanceFormatting`** + + ```bash + cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceFormatting.swift native/macos/MCPProxy/MCPProxyTests/GlanceFormattingTests.swift && git commit -m "feat(tray): add GlanceFormatting pure helpers for glance rows + + Status symbol, server:tool row label, middle truncation and a compact + locale-free relative age, salvaged from the dead Menu/TrayMenu.swift. + Eleven unit tests; no AppKit, no networking." + ``` + +- [ ] **Step 6: Write the failing selection test for rules 1-3** + + Create `native/macos/MCPProxy/MCPProxyTests/GlanceSelectionTests.swift` with exactly this content: + + ```swift + import XCTest + @testable import MCPProxy + + final class GlanceSelectionTests: XCTestCase { + + // MARK: - Rule 2 + + func testUpstreamToolCallIsAlwaysIncluded() { + let entry = Self.entry(id: "1", type: "tool_call", server: "github", tool: "create_issue") + XCTAssertTrue(GlanceSelection.qualifies(entry)) + } + + // MARK: - Rule 3 + + func testDiscoveryAndExecutionBuiltInsAreIncluded() { + for tool in ["retrieve_tools", "code_execution", "describe_tool"] { + let entry = Self.entry(id: tool, type: "internal_tool_call", tool: tool) + XCTAssertTrue(GlanceSelection.qualifies(entry), "\(tool) should qualify") + } + } + + func testSuccessfulCallToolWrapperIsExcluded() { + let entry = Self.entry(id: "w", type: "internal_tool_call", tool: "call_tool_write") + XCTAssertFalse(GlanceSelection.qualifies(entry)) + } + + func testFailedCallToolWrapperIsIncluded() { + let entry = Self.entry(id: "w", type: "internal_tool_call", tool: "call_tool_write", status: "error") + XCTAssertTrue(GlanceSelection.qualifies(entry), "pre-dispatch failures have no upstream record") + } + + // MARK: - Rule 1 beats rule 3 + + func testManagementBuiltInsAreExcludedEvenWhenTheyFail() { + for tool in ["upstream_servers", "quarantine_security"] { + let ok = Self.entry(id: "\(tool)-ok", type: "internal_tool_call", tool: tool) + let bad = Self.entry(id: "\(tool)-bad", type: "internal_tool_call", tool: tool, status: "error") + XCTAssertFalse(GlanceSelection.qualifies(ok), "\(tool) success must be excluded") + XCTAssertFalse(GlanceSelection.qualifies(bad), "\(tool) failure must be excluded (rule 1 beats rule 3)") + } + } + + func testNonActivityTypesAreExcluded() { + XCTAssertFalse(GlanceSelection.qualifies(Self.entry(id: "s", type: "security_scan"))) + XCTAssertFalse(GlanceSelection.qualifies(Self.entry(id: "o", type: "oauth_event", status: "error"))) + } + + // MARK: - Helpers + + static func entry( + id: String, + type: String, + server: String? = nil, + tool: String? = nil, + status: String = "success", + requestId: String? = nil + ) -> ActivityEntry { + var json: [String: Any] = [ + "id": id, + "type": type, + "status": status, + "timestamp": "2027-01-15T08:00:00Z" + ] + if let server { json["server_name"] = server } + if let tool { json["tool_name"] = tool } + if let requestId { json["request_id"] = requestId } + let data = try! JSONSerialization.data(withJSONObject: json) + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(ActivityEntry.self, from: data) + } + + static func session(id: String, status: String, clientName: String = "Claude Code") -> APIClient.MCPSession { + let json: [String: Any] = [ + "id": id, + "status": status, + "client_name": clientName, + "tool_call_count": 3 + ] + let data = try! JSONSerialization.data(withJSONObject: json) + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(APIClient.MCPSession.self, from: data) + } + } + ``` + + `entry` and `session` are deliberately non-`private` statics: the collapse tests added in Step 11 live in a second file and reuse them. `session` is unused until Step 11 — Swift does not warn about unused methods, so this compiles cleanly now. Every `MCPSession` field other than `id` and `status` is optional (`APIClient.swift:332-341`), so the four-key JSON decodes. + +- [ ] **Step 7: Run the selection test and watch it fail to compile** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceSelectionTests + ``` + + Expected output (one diagnostic per reference, elided after the first three): + + ``` + [52/63] Compiling MCPProxyTests GlanceSelectionTests.swift + /Users/user/repos/mcpproxy-go/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionTests.swift:10:23: error: cannot find 'GlanceSelection' in scope + /Users/user/repos/mcpproxy-go/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionTests.swift:18:27: error: cannot find 'GlanceSelection' in scope + /Users/user/repos/mcpproxy-go/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionTests.swift:24:24: error: cannot find 'GlanceSelection' in scope + ... + error: fatalError + ``` + +- [ ] **Step 8: Implement rules 1-3 in `GlanceSelection`** + + Create `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift` with exactly this content (Step 13 replaces this file with the full version — this is the minimal implementation that turns Step 7's tests green and nothing more): + + ```swift + // GlanceSelection.swift + // MCPProxy + // + // Display rules for the tray glance section: which activity records qualify + // as rows, how duplicates collapse, and which sessions count as clients. + // Pure functions over ActivityEntry / APIClient.MCPSession — no AppKit. + + import Foundation + + /// Presentation policy for the glance section. Pure and synchronous. + enum GlanceSelection { + + /// Proxy administration built-ins. Never shown, whatever their status. + static let managementBuiltIns: Set = ["upstream_servers", "quarantine_security"] + + /// Discovery/execution built-ins that are worth a row even on success. + static let glanceInternalTools: Set = ["retrieve_tools", "code_execution", "describe_tool"] + + // MARK: - Rules 1-3 + + /// Whether a single record qualifies for a glance row. + static func qualifies(_ entry: ActivityEntry) -> Bool { + let tool = entry.toolName ?? "" + + // Rule 1 — management built-ins are excluded, whatever the status. + if managementBuiltIns.contains(tool) { return false } + + // Rule 2 — every real upstream call. + if entry.type == "tool_call" { return true } + + // Rule 3 — discovery/execution built-ins, plus any internal failure + // (a wrapper that died before dispatch has no upstream record). + if entry.type == "internal_tool_call" { + return glanceInternalTools.contains(tool) || entry.status != "success" + } + + return false + } + } + ``` + + The `return false` at the end is what excludes every other activity type — `security_scan`, `oauth_event`, quarantine changes — from the glance rows, while leaving `AppState.recentActivity` (which the native Dashboard renders in full) untouched. + +- [ ] **Step 9: Run the selection test and watch it pass** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceSelectionTests + ``` + + Expected output (tail): + + ``` + Test Suite 'GlanceSelectionTests' passed at 2026-07-29 15:19:37.808. + Executed 6 tests, with 0 failures (0 unexpected) in 0.001 (0.001) seconds + Test Suite 'MCPProxyPackageTests.xctest' passed at 2026-07-29 15:19:37.808. + Executed 6 tests, with 0 failures (0 unexpected) in 0.001 (0.001) seconds + Test Suite 'Selected tests' passed at 2026-07-29 15:19:37.808. + Executed 6 tests, with 0 failures (0 unexpected) in 0.001 (0.005) seconds + ``` + +- [ ] **Step 10: Commit the qualification rules** + + ```bash + cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift native/macos/MCPProxy/MCPProxyTests/GlanceSelectionTests.swift && git commit -m "feat(tray): add GlanceSelection qualification rules 1-3 + + Management built-ins (upstream_servers, quarantine_security) are excluded + whatever their status; every tool_call qualifies; internal_tool_call + qualifies for the three discovery/execution built-ins or on any failure, + so a pre-dispatch call_tool_* failure still gets a row." + ``` + +- [ ] **Step 11: Write the failing test for rule 4, capping, and the clients helper** + + Create `native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift` with exactly this content: + + ```swift + import XCTest + @testable import MCPProxy + + final class GlanceSelectionCollapseTests: XCTestCase { + + // MARK: - Rule 4 + + func testPairedFailureCollapsesToTheUpstreamRecord() { + let entries = [ + GlanceSelectionTests.entry(id: "wrapper", type: "internal_tool_call", tool: "call_tool_read", + status: "error", requestId: "req-1"), + GlanceSelectionTests.entry(id: "upstream", type: "tool_call", server: "jira", tool: "get_issue", + status: "error", requestId: "req-1") + ] + let rows = GlanceSelection.activityRows(from: entries) + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows[0].id, "upstream") + XCTAssertEqual(rows[0].serverName, "jira") + } + + func testPreDispatchWrapperFailureWithNoPairStillRenders() { + let entries = [ + GlanceSelectionTests.entry(id: "wrapper", type: "internal_tool_call", tool: "call_tool_read", + status: "error", requestId: "req-2") + ] + let rows = GlanceSelection.activityRows(from: entries) + XCTAssertEqual(rows.map(\.id), ["wrapper"]) + } + + func testRecordsWithoutRequestIDsAreNeverCollapsed() { + let entries = [ + GlanceSelectionTests.entry(id: "a", type: "tool_call", server: "s", tool: "t"), + GlanceSelectionTests.entry(id: "b", type: "tool_call", server: "s", tool: "t") + ] + XCTAssertEqual(GlanceSelection.activityRows(from: entries).map(\.id), ["a", "b"]) + } + + func testCollapsedRowKeepsTheGroupsRecencyPosition() { + let entries = [ + GlanceSelectionTests.entry(id: "newest", type: "tool_call", server: "a", tool: "t", requestId: "r-9"), + GlanceSelectionTests.entry(id: "wrapper", type: "internal_tool_call", tool: "call_tool_read", + status: "error", requestId: "r-8"), + GlanceSelectionTests.entry(id: "upstream", type: "tool_call", server: "b", tool: "t", + status: "error", requestId: "r-8") + ] + XCTAssertEqual(GlanceSelection.activityRows(from: entries).map(\.id), ["newest", "upstream"]) + } + + // MARK: - Capping over a realistic page + + func testFiveRowsAreSelectedFromAFiftyRecordPageFullOfNoise() { + var page: [ActivityEntry] = [] + // 40 management-built-in calls arrive first — rule 1 must drop them all. + for i in 0..<40 { + page.append(GlanceSelectionTests.entry( + id: "mgmt-\(i)", type: "internal_tool_call", + tool: i.isMultiple(of: 2) ? "upstream_servers" : "quarantine_security")) + } + // 4 successful wrappers — rule 3 drops them. + for i in 0..<4 { + page.append(GlanceSelectionTests.entry( + id: "wrap-\(i)", type: "internal_tool_call", tool: "call_tool_read")) + } + // 6 real calls — only the first five become rows. + for i in 0..<6 { + page.append(GlanceSelectionTests.entry( + id: "call-\(i)", type: "tool_call", server: "srv", tool: "tool\(i)")) + } + XCTAssertEqual(page.count, 50) + + let rows = GlanceSelection.activityRows(from: page) + XCTAssertEqual(rows.map(\.id), ["call-0", "call-1", "call-2", "call-3", "call-4"]) + } + + func testFewerThanFiveQualifyingRecordsYieldsWhatThereIs() { + let rows = GlanceSelection.activityRows(from: [ + GlanceSelectionTests.entry(id: "call-0", type: "tool_call", server: "srv", tool: "t") + ]) + XCTAssertEqual(rows.count, 1) + } + + func testEmptyInputYieldsNoRows() { + XCTAssertTrue(GlanceSelection.activityRows(from: []).isEmpty) + } + + // MARK: - Clients + + func testActiveClientsFiltersClosedSessionsAndCapsAtFive() { + var sessions = [GlanceSelectionTests.session(id: "closed-1", status: "closed")] + for i in 0..<7 { + sessions.append(GlanceSelectionTests.session(id: "active-\(i)", status: "active")) + } + sessions.append(GlanceSelectionTests.session(id: "closed-2", status: "closed")) + + let clients = GlanceSelection.activeClients(from: sessions) + XCTAssertEqual(clients.map(\.id), ["active-0", "active-1", "active-2", "active-3", "active-4"]) + } + + func testActiveClientsIsEmptyWhenEverySessionIsClosed() { + let sessions = [ + GlanceSelectionTests.session(id: "a", status: "closed"), + GlanceSelectionTests.session(id: "b", status: "closed") + ] + XCTAssertTrue(GlanceSelection.activeClients(from: sessions).isEmpty) + } + } + ``` + + `testFiveRowsAreSelectedFromAFiftyRecordPageFullOfNoise` uses 50 records on purpose: that is the page size the production activity poll requests, and 40 leading management-built-in records is the exact burst scenario the design's oversized page exists to survive. + +- [ ] **Step 12: Run the collapse test and watch it fail to compile** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceSelectionCollapseTests + ``` + + Expected output — `GlanceSelection` exists now, so the errors are missing members rather than a missing type: + + ``` + /Users/user/repos/mcpproxy-go/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift:15:36: error: type 'GlanceSelection' has no member 'activityRows' + /Users/user/repos/mcpproxy-go/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift:26:36: error: type 'GlanceSelection' has no member 'activityRows' + /Users/user/repos/mcpproxy-go/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift:27:33: error: cannot infer key path type from context; consider explicitly specifying a root type + ... (the same pair repeats for every activityRows call site: 35, 46, 68, 73, 80) ... + /Users/user/repos/mcpproxy-go/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift:92:39: error: type 'GlanceSelection' has no member 'activeClients' + /Users/user/repos/mcpproxy-go/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift:98:39: error: type 'GlanceSelection' has no member 'activeClients' + error: fatalError + ``` + + The odder-looking key-path errors are knock-ons: with `activityRows` / `activeClients` unresolved the compiler cannot type `rows.map(\.id)`. They disappear with the missing members. + +- [ ] **Step 13: Implement rule 4, `activityRows` and `activeClients`** + + Replace the entire contents of `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift` with this (rules 1-3 are unchanged; `rowLimit`, `collapseByRequestID`, `activityRows` and `activeClients` are new): + + ```swift + // GlanceSelection.swift + // MCPProxy + // + // Display rules for the tray glance section: which activity records qualify + // as rows, how duplicates collapse, and which sessions count as clients. + // Pure functions over ActivityEntry / APIClient.MCPSession — no AppKit. + + import Foundation + + /// Presentation policy for the glance section. Pure and synchronous. + enum GlanceSelection { + + /// Proxy administration built-ins. Never shown, whatever their status. + static let managementBuiltIns: Set = ["upstream_servers", "quarantine_security"] + + /// Discovery/execution built-ins that are worth a row even on success. + static let glanceInternalTools: Set = ["retrieve_tools", "code_execution", "describe_tool"] + + /// How many rows each list shows. + static let rowLimit = 5 + + // MARK: - Rules 1-3 + + /// Whether a single record qualifies for a glance row. + static func qualifies(_ entry: ActivityEntry) -> Bool { + let tool = entry.toolName ?? "" + + // Rule 1 — management built-ins are excluded, whatever the status. + if managementBuiltIns.contains(tool) { return false } + + // Rule 2 — every real upstream call. + if entry.type == "tool_call" { return true } + + // Rule 3 — discovery/execution built-ins, plus any internal failure + // (a wrapper that died before dispatch has no upstream record). + if entry.type == "internal_tool_call" { + return glanceInternalTools.contains(tool) || entry.status != "success" + } + + return false + } + + // MARK: - Rule 4 + + /// Collapse records sharing a `request_id`, keeping the `tool_call` one. + /// + /// The surviving record is emitted at the position of the first record of + /// its group so recency ordering is preserved. Records with no request id + /// are never collapsed. + static func collapseByRequestID(_ entries: [ActivityEntry]) -> [ActivityEntry] { + var winners: [String: ActivityEntry] = [:] + for entry in entries { + guard let rid = entry.requestId, !rid.isEmpty else { continue } + guard let existing = winners[rid] else { + winners[rid] = entry + continue + } + if existing.type != "tool_call" && entry.type == "tool_call" { + winners[rid] = entry + } + } + + var emitted = Set() + var result: [ActivityEntry] = [] + for entry in entries { + guard let rid = entry.requestId, !rid.isEmpty else { + result.append(entry) + continue + } + if emitted.contains(rid) { continue } + emitted.insert(rid) + result.append(winners[rid] ?? entry) + } + return result + } + + // MARK: - Public entry points + + /// Rules 1-4 applied in order, then the first `limit` survivors. + static func activityRows(from entries: [ActivityEntry], limit: Int = rowLimit) -> [ActivityEntry] { + let qualified = entries.filter(qualifies) + return Array(collapseByRequestID(qualified).prefix(limit)) + } + + /// Sessions currently connected, capped at `limit`, input order preserved. + static func activeClients( + from sessions: [APIClient.MCPSession], + limit: Int = rowLimit + ) -> [APIClient.MCPSession] { + Array(sessions.filter { $0.status == "active" }.prefix(limit)) + } + } + ``` + + The two-pass structure of `collapseByRequestID` is what makes `testCollapsedRowKeepsTheGroupsRecencyPosition` pass: pass one picks the winner per request id, pass two emits it at the *first* position that group occupies. A single-pass "keep the last `tool_call` seen" would reorder rows whenever the wrapper record arrived before its upstream partner, which is exactly the order the two SSE events fire in. + +- [ ] **Step 14: Run all three Glance test classes and watch them pass** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter "GlanceSelection|GlanceFormatting" + ``` + + Expected output (tail) — 11 formatting + 6 qualification + 9 collapse/clients = 26: + + ``` + Test Suite 'MCPProxyPackageTests.xctest' passed at 2026-07-29 15:20:24.799. + Executed 26 tests, with 0 failures (0 unexpected) in 0.004 (0.006) seconds + Test Suite 'Selected tests' passed at 2026-07-29 15:20:24.799. + Executed 26 tests, with 0 failures (0 unexpected) in 0.004 (0.011) seconds + ◇ Test run started. + ↳ Testing Library Version: 1902 + ↳ Target Platform: arm64e-apple-macos14.0 + ✔ Test run with 0 tests in 0 suites passed after 0.001 seconds. + ``` + +- [ ] **Step 15: Run the whole native suite — the exact command CI runs** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test + ``` + + Expected output (tail): + + ``` + Test Suite 'MCPProxyPackageTests.xctest' passed at 2026-07-29 15:21:03.913. + Executed 315 tests, with 0 failures (0 unexpected) in 0.073 (0.083) seconds + Test Suite 'All tests' passed at 2026-07-29 15:21:03.913. + Executed 315 tests, with 0 failures (0 unexpected) in 0.073 (0.088) seconds + ``` + + 289 of those are the pre-existing suite and 26 are new here; the total climbs as sibling tasks in this plan land their own tests, so treat `0 failures` as the pass criterion rather than the count. This is verbatim what the `swift-test` job in `.github/workflows/native-tests.yml:83` runs (`working-directory: native/macos/MCPProxy`, `run: swift test`). Do not add `--skip` to make it green — that workflow carries an inline comment forbidding it. + +- [ ] **Step 16: Commit rule 4, capping, and the clients helper** + + ```bash + cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift && git commit -m "feat(tray): collapse paired glance records and cap the row lists + + A failed upstream call persists both a tool_call and a call_tool_* wrapper + under one request id; rule 4 keeps the tool_call one because it names the + real server:tool. Adds activityRows() (rules 1-4 then first five) and + activeClients() (status==active, first five). Nine more unit tests." + ``` + +--- + +### Task 6: Swift — GlanceSection menu items + in-place row updates + +**Files:** +- Create: `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift` +- Test (create): `native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift` +- No existing file is modified. `native/macos/MCPProxy/Package.swift` uses path-based globbing (`.executableTarget(name: "MCPProxy", path: "MCPProxy")`, `.testTarget(name: "MCPProxyTests", path: "MCPProxyTests")`), so **dropping a new `.swift` file into either directory needs no target registration** — the package is SPM-only (there is no `.xcodeproj`; `scripts/build-macos-tray.sh` shells out to `swift build`). Wiring these items into `MCPProxyApp.rebuildMenu()` is a later task; this task ships the component and its tests only. + +**Interfaces:** + +*Consumes — already on disk, signatures verified verbatim:* +- `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceFormatting.swift` + - `static func statusSymbolName(for entry: ActivityEntry) -> String` + - `static func rowLabel(for entry: ActivityEntry) -> String` + - `static func middleTruncated(_ text: String, limit: Int) -> String` + - `static func relativeTime(_ timestamp: String, now: Date = Date()) -> String` + - `static func parseTimestamp(_ timestamp: String) -> Date?` +- `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift` + - `static func activityRows(from entries: [ActivityEntry], limit: Int = rowLimit) -> [ActivityEntry]` + - `static func activeClients(from sessions: [APIClient.MCPSession], limit: Int = rowLimit) -> [APIClient.MCPSession]` + - `static let rowLimit = 5` +- `native/macos/MCPProxy/MCPProxy/API/Models.swift` — `struct ActivityEntry: Codable, Identifiable, Equatable` (`:536`) with `id`, `type`, `serverName`, `toolName`, `status`, `errorMessage`, `timestamp`, `sessionId`, `requestId` and the snake_case coding keys the fixtures use (`server_name`, `tool_name`, `error_message`, `session_id`, `request_id`). +- `native/macos/MCPProxy/MCPProxy/State/AppState.swift` — `@Published var glanceActivity: [ActivityEntry]`, `@Published var glanceSessions: [APIClient.MCPSession]`, `@Published var usageTimeline: [UsageBucket]?`, `@Published var callsThisHour: Int?`, `var isConnected: Bool` (`coreState == .connected`), `@Published var isStopped: Bool`, `@Published var coreState: CoreState` whose `didSet` calls `clearGlanceState()` on any state other than `.connected`. + +*Consumes — produced by earlier tasks in this plan; do NOT assume it is on disk, run Step 0 first:* +- `struct UsageBucket` with the memberwise init `UsageBucket(start:calls:errors:totalRespBytes:)`. It currently lives in the placeholder `native/macos/MCPProxy/MCPProxy/API/UsageStub.swift` as `struct UsageBucket: Equatable` plus a separate `extension UsageBucket: Codable` — **not** in `Models.swift`. Wherever the API task finally puts it, the type name and that memberwise init must survive; this task depends on nothing else about it. +- `APIClient.MCPSession.lastActivity` (coding key `last_activity`). On disk today `APIClient.swift:341` still declares `let lastActive: String?` with `:353` mapping `last_active`. The design requires the rename ("`APIClient.swift:341,353` — decode `last_activity`, the field the API emits"), and it carries five call sites in `MCPProxy/Views/DashboardView.swift` (`:481`, `:482`, `:494`, `:495`, `:553`). Until that task lands, this task's client-row test decodes `lastActivity` as nil and the implementation does not compile. + +*Produces* (what the integration task and the histogram task rely on): +```swift +final class GlanceSection { + init(target: AnyObject?, action: Selector) + var histogramViewBuilder: (([UsageBucket]) -> NSView)? + func isVisible(for state: AppState) -> Bool + func items(for state: AppState, now: Date = Date()) -> [NSMenuItem] + @discardableResult func updateInPlace(for state: AppState, now: Date = Date()) -> Bool + static func firstClause(of message: String?) -> String? +} +``` +Contract for the caller: +- `items(for:)` returns `[]` when the core is stopped or not connected, otherwise a 12-item block (fewer/more rows only as the record counts change) that **already ends with `.separator()`** — splice it in where the header separator is today (`MCPProxyApp.swift:591`, i.e. the `menu.addItem(.separator())` that sits after the status/error header and before the "Needs Attention" block) and emit the plain separator only when the returned array is empty. +- Ordering: `[0]` header summary, `[1]` separator, `[2]` "Recent", `[3…]` activity rows (or one "No tool calls yet" row), then "Open Activity…", separator, "Clients", client rows (or "No connected clients"), separator, "Activity (24h)" (submenu owner), separator. +- Every clickable row carries `target`/`action` as passed to `init`, and `representedObject` is the record's session id (`String?`). The "Open Activity…" row's `representedObject` is `nil` — the delegate must treat nil as "open the unfiltered activity log" (a record with no `session_id` degrades to the same behaviour). `GlanceSection` never builds a URL: the API key lives on `CoreProcessManager`. +- `updateInPlace(for:)` returns `false` for any structural change (visibility flip, row-count change, histogram loaded-ness flip) and leaves every row untouched; the caller must then set a dirty flag and run a full `rebuildMenu()` on `menuDidClose`. `true` means every row's title, image, tooltip, accessibility label and `representedObject` were rewritten in place. +- `histogramViewBuilder` is the seam for the SwiftUI chart: while it is nil the submenu renders a plain text line, so the component compiles and tests without SwiftUI Charts. + +--- + +- [ ] **Step 0: Preflight — confirm the prerequisites this task consumes** + +This task sits mid-plan and its fixtures reference symbols that earlier tasks introduce. Run the checks before writing anything; if one fails, finish the earlier task first rather than working around it here. + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy +# 1. AppState glance feeds + the disconnect reset (earlier task) +grep -n "glanceActivity\|glanceSessions\|usageTimeline\|callsThisHour\|clearGlanceState" MCPProxy/State/AppState.swift +# 2. The session field rename the design requires — MUST print lastActivity/last_activity, not lastActive/last_active +grep -n "lastActiv" MCPProxy/API/APIClient.swift +# 3. UsageBucket must exist somewhere in the module with a memberwise init +grep -rn "struct UsageBucket" MCPProxy/ +# 4. The test target must currently build — otherwise Step 2's failure is unreadable +swift build --build-tests 2>&1 | tail -5 +``` + +If check 2 still prints `lastActive` / `last_active`, the client-row test in Step 11 cannot pass: stop and land the `APIClient.swift:341,353` rename (plus its five `Views/DashboardView.swift` call sites at `:481`, `:482`, `:494`, `:495`, `:553`) first. If check 4 reports errors in files this task does not touch, the same applies — a broken test target makes every "watch it fail" step below meaningless. + +- [ ] **Step 1: Create the test file with fixtures, the visibility tests and the header tests** + +Create `native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift` with exactly this content: + +```swift +import XCTest +import AppKit +@testable import MCPProxy + +@MainActor +final class GlanceSectionTests: XCTestCase { + + /// Fixed clock so relative ages are deterministic. + static let now = GlanceFormatting.parseTimestamp("2027-01-15T08:00:00Z")! + + // MARK: - Visibility + + func testBlockIsHiddenWhenCoreIsNotConnected() { + let state = Self.busyState() + state.coreState = .idle + let section = Self.makeSection() + XCTAssertEqual(section.items(for: state, now: Self.now).count, 0) + } + + func testBlockIsHiddenWhenUserStoppedTheCore() { + let state = Self.busyState() + state.isStopped = true + let section = Self.makeSection() + XCTAssertEqual(section.items(for: state, now: Self.now).count, 0) + } + + // MARK: - Header + + func testHeaderShowsCallsThisHourAndClientCount() { + let section = Self.makeSection() + let items = section.items(for: Self.busyState(), now: Self.now) + XCTAssertEqual(items.first?.title, "12 calls this hour · 1 client") + XCTAssertFalse(items[0].isEnabled, "the header is a muted, non-clickable line") + } + + func testHeaderOmitsCallCountUntilUsageLoads() { + let state = Self.busyState() + state.callsThisHour = nil + let section = Self.makeSection() + XCTAssertEqual(section.items(for: state, now: Self.now).first?.title, "1 client") + } + + // MARK: - Helpers + + private final class ClickStub: NSObject { + @objc func openGlanceRow(_ sender: NSMenuItem) {} + } + + private static let clickStub = ClickStub() + + private static func makeSection() -> GlanceSection { + GlanceSection(target: clickStub, action: #selector(ClickStub.openGlanceRow(_:))) + } + + /// A connected core with two qualifying calls and one active client. + private static func busyState() -> AppState { + let state = AppState() + // coreState first: its didSet clears the glance feeds on any non-connected state. + state.coreState = .connected + state.callsThisHour = 12 + state.glanceActivity = [ + entry(id: "a", server: "github", tool: "create_issue", + timestamp: "2027-01-15T07:59:30Z", session: "sess-a"), + entry(id: "b", server: "jira", tool: "get_issue", status: "error", + error: "auth failed: token expired. retry after refresh", + timestamp: "2027-01-15T07:58:00Z", session: "sess-b") + ] + state.glanceSessions = [ + session(id: "sess-a", name: "Claude Code", version: "2.1.0", + calls: 8, lastActivity: "2027-01-15T07:59:00Z") + ] + return state + } + + private static func entry( + id: String, + type: String = "tool_call", + server: String? = nil, + tool: String? = nil, + status: String = "success", + error: String? = nil, + timestamp: String, + session: String? = nil + ) -> ActivityEntry { + var json: [String: Any] = [ + "id": id, + "type": type, + "status": status, + "timestamp": timestamp, + "request_id": "req-\(id)" + ] + if let server { json["server_name"] = server } + if let tool { json["tool_name"] = tool } + if let error { json["error_message"] = error } + if let session { json["session_id"] = session } + let data = try! JSONSerialization.data(withJSONObject: json) + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(ActivityEntry.self, from: data) + } + + private static func session( + id: String, + name: String, + version: String, + calls: Int, + lastActivity: String + ) -> APIClient.MCPSession { + let json: [String: Any] = [ + "id": id, + "status": "active", + "client_name": name, + "client_version": version, + "tool_call_count": calls, + "last_activity": lastActivity + ] + let data = try! JSONSerialization.data(withJSONObject: json) + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(APIClient.MCPSession.self, from: data) + } +} +``` + +- [ ] **Step 2: Run the tests and watch them fail to compile** + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceSectionTests +``` + +Expected output — the type does not exist yet, so the test target fails to build. The **first** error is the return type of `makeSection()` on line 51, not the constructor call: + +``` +/Users/user/repos/mcpproxy-go/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift:51:42: error: cannot find type 'GlanceSection' in scope + 51 | private static func makeSection() -> GlanceSection { + | `- error: cannot find type 'GlanceSection' in scope +/Users/user/repos/mcpproxy-go/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift:52:9: error: cannot find 'GlanceSection' in scope + 52 | GlanceSection(target: clickStub, action: #selector(ClickStub.openGlanceRow(_:))) + | `- error: cannot find 'GlanceSection' in scope +error: fatalError +``` + +If you also see errors from `AppStateGlanceTests.swift` or other files you did not touch, Step 0's check 4 was skipped — stop and land the earlier task. + +- [ ] **Step 3: Create GlanceSection with the visibility rule and the header line** + +Create `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift`: + +```swift +// GlanceSection.swift +// MCPProxy +// +// Builds the "glance" block at the top of the tray menu: a one-line summary, +// the most recent qualifying tool calls, the active MCP clients, and the +// 24h histogram submenu. +// +// Every text row is a plain NSMenuItem. Custom (view-backed) menu items receive +// mouse events but NOT keyboard events, so building the rows as hosted SwiftUI +// would silently cost keyboard navigation and VoiceOver. Only the histogram — +// which genuinely needs drawing — is view-backed, and it lives alone inside its +// own submenu. +// +// This component never builds a Web UI URL. It is handed only AppState, whose +// webUIBaseURL is scheme/host/port by design, while the API key lives on the +// core manager. Rows therefore carry a target/action pair plus a +// representedObject holding the record's session id, and the app delegate opens +// the authenticated URL through the same path as every other menu action. +// +// Deliberately NOT @MainActor: AppController (the NSApplicationDelegate that +// will call this, MCPProxyApp.swift:15) is not actor-isolated, and this SDK does +// not infer MainActor from NSApplicationDelegate conformance, so annotating it +// would make rebuildMenu() fail to compile. + +import AppKit + +final class GlanceSection { + + // MARK: Click routing + + private weak var clickTarget: AnyObject? + private let clickAction: Selector + + // MARK: Owned items (kept so rows can be rewritten in place) + + private var summaryItem: NSMenuItem? + + init(target: AnyObject?, action: Selector) { + self.clickTarget = target + self.clickAction = action + } + + // MARK: Building + + /// Whether the glance block should appear at all. When the core is stopped + /// or disconnected the block is hidden entirely, rather than presenting the + /// previous core's numbers as if they were live. + func isVisible(for state: AppState) -> Bool { + state.isConnected && !state.isStopped + } + + /// Build the whole block, ordered top to bottom and ending with a separator + /// so the caller can splice it into the menu in one go. Returns an empty + /// array when the block is hidden. + func items(for state: AppState, now: Date = Date()) -> [NSMenuItem] { + summaryItem = nil + guard isVisible(for: state) else { return [] } + + var items: [NSMenuItem] = [] + let summary = disabledItem(titled: summaryTitle(for: state)) + summaryItem = summary + items.append(summary) + items.append(.separator()) + return items + } + + // MARK: Header + + private func summaryTitle(for state: AppState) -> String { + var parts: [String] = [] + if let calls = state.callsThisHour { + parts.append(calls == 1 ? "1 call this hour" : "\(calls) calls this hour") + } + let clients = GlanceSelection.activeClients(from: state.glanceSessions, limit: Int.max).count + parts.append(clients == 1 ? "1 client" : "\(clients) clients") + return parts.joined(separator: " · ") + } + + // MARK: Item factories + + private func disabledItem(titled title: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + return item + } +} +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceSectionTests +``` + +Expected output (tail): + +``` +Test Case '-[MCPProxyTests.GlanceSectionTests testHeaderOmitsCallCountUntilUsageLoads]' passed (0.001 seconds). +Test Suite 'GlanceSectionTests' passed at ... + Executed 4 tests, with 0 failures (0 unexpected) in 0.034 (0.040) seconds +``` + +- [ ] **Step 5: Commit the skeleton** + +Confirm you are on the feature branch for this plan first — do not commit to `main`: + +```bash +cd /Users/user/repos/mcpproxy-go && git rev-parse --abbrev-ref HEAD # must NOT print "main" +git add native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift && git commit -m "feat(tray): glance section skeleton — visibility rule and header summary" +``` + +- [ ] **Step 6: Add the Recent-section tests** + +In `native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift`, insert this block immediately before the `// MARK: - Helpers` line: + +```swift + // MARK: - Recent section + + func testRecentSectionRendersQualifyingRows() { + let section = Self.makeSection() + let titles = section.items(for: Self.busyState(), now: Self.now).map { + $0.isSeparatorItem ? "—" : $0.title + } + XCTAssertEqual(Array(titles.prefix(6)), [ + "12 calls this hour · 1 client", + "—", + "Recent", + "github:create_issue — 30s", + "jira:get_issue · auth failed — 2m", + "Open Activity…" + ]) + } + + func testActivityRowCarriesFullIdentity() { + let section = Self.makeSection() + let failed = section.items(for: Self.busyState(), now: Self.now)[4] + XCTAssertEqual(failed.title, "jira:get_issue · auth failed — 2m") + XCTAssertEqual(failed.representedObject as? String, "sess-b") + XCTAssertEqual(failed.image?.accessibilityDescription, "failed") + XCTAssertEqual(failed.toolTip, "jira:get_issue\nauth failed: token expired. retry after refresh") + XCTAssertEqual(failed.accessibilityLabel(), "jira:get_issue, failed: auth failed, 2m ago") + XCTAssertNotNil(failed.action) + } + + func testOpenActivityRowHasNoSessionPayload() { + let section = Self.makeSection() + let items = section.items(for: Self.busyState(), now: Self.now) + XCTAssertEqual(items[5].title, "Open Activity…") + XCTAssertNil(items[5].representedObject) + XCTAssertNotNil(items[5].action) + } + + func testNoActivityShowsOneMutedRow() { + let state = Self.busyState() + state.glanceActivity = [] + let section = Self.makeSection() + let row = section.items(for: state, now: Self.now)[3] + XCTAssertEqual(row.title, "No tool calls yet") + XCTAssertFalse(row.isEnabled) + } + + func testFirstClauseKeepsOnlyTheLeadingClause() { + XCTAssertEqual(GlanceSection.firstClause(of: "auth failed: token expired"), "auth failed") + XCTAssertEqual(GlanceSection.firstClause(of: "dial tcp 127.0.0.1"), "dial tcp 127") + XCTAssertEqual(GlanceSection.firstClause(of: " boom "), "boom") + XCTAssertNil(GlanceSection.firstClause(of: " ")) + XCTAssertNil(GlanceSection.firstClause(of: nil)) + } + +``` + +- [ ] **Step 7: Run the tests and watch them fail** + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceSectionTests +``` + +Expected output — the new tests reference a member that does not exist yet: + +``` +/Users/user/repos/mcpproxy-go/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift:89:38: error: type 'GlanceSection' has no member 'firstClause' + 89 | XCTAssertEqual(GlanceSection.firstClause(of: "auth failed: token expired"), "auth failed") + | `- error: type 'GlanceSection' has no member 'firstClause' +error: fatalError +``` + +- [ ] **Step 8: Implement the Recent section and the activity-row renderer** + +In `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift` make three edits. + +(a) Replace the stored-properties block + +```swift + // MARK: Owned items (kept so rows can be rewritten in place) + + private var summaryItem: NSMenuItem? +``` + +with + +```swift + // MARK: Configuration + + /// Character budget for a row label before middle truncation kicks in. + private static let labelBudget = 34 + + // MARK: Owned items (kept so rows can be rewritten in place) + + private var summaryItem: NSMenuItem? + private var activityRows: [NSMenuItem] = [] +``` + +(b) Replace the whole body of `items(for:now:)` + +```swift + summaryItem = nil + guard isVisible(for: state) else { return [] } + + var items: [NSMenuItem] = [] + let summary = disabledItem(titled: summaryTitle(for: state)) + summaryItem = summary + items.append(summary) + items.append(.separator()) + return items +``` + +with + +```swift + summaryItem = nil + activityRows = [] + guard isVisible(for: state) else { return [] } + + var items: [NSMenuItem] = [] + + let summary = disabledItem(titled: summaryTitle(for: state)) + summaryItem = summary + items.append(summary) + items.append(.separator()) + + items.append(disabledItem(titled: "Recent")) + let entries = GlanceSelection.activityRows(from: state.glanceActivity) + if entries.isEmpty { + items.append(disabledItem(titled: "No tool calls yet")) + } else { + for entry in entries { + let row = actionableItem() + apply(entry, to: row, now: now) + activityRows.append(row) + items.append(row) + } + } + + let openActivity = actionableItem() + openActivity.title = "Open Activity…" + openActivity.image = NSImage(systemSymbolName: "list.bullet.rectangle", + accessibilityDescription: "activity log") + items.append(openActivity) + + return items +``` + +(c) Insert these members immediately after the closing brace of `items(for:now:)`, before `// MARK: Header`: + +```swift + // MARK: Row rendering + + /// Rewrite an activity row so its title, icon, tooltip, accessibility label + /// and click payload all describe `entry`. + private func apply(_ entry: ActivityEntry, to item: NSMenuItem, now: Date) { + let fullLabel = GlanceFormatting.rowLabel(for: entry) + let label = GlanceFormatting.middleTruncated(fullLabel, limit: Self.labelBudget) + let age = GlanceFormatting.relativeTime(entry.timestamp, now: now) + let failed = entry.status != "success" + let detail = failed ? Self.firstClause(of: entry.errorMessage) : nil + + if let detail { + item.title = "\(label) · \(detail) — \(age)" + item.setAccessibilityLabel("\(fullLabel), failed: \(detail), \(age) ago") + } else { + item.title = "\(label) — \(age)" + item.setAccessibilityLabel("\(fullLabel), \(failed ? "failed" : "succeeded"), \(age) ago") + } + + item.image = NSImage(systemSymbolName: GlanceFormatting.statusSymbolName(for: entry), + accessibilityDescription: failed ? "failed" : "succeeded") + + if let message = entry.errorMessage, !message.isEmpty { + item.toolTip = "\(fullLabel)\n\(message)" + } else { + item.toolTip = fullLabel + } + + item.representedObject = entry.sessionId + } + + /// First clause of an error message — everything up to the first newline, + /// period or colon — so a multi-sentence backend error still fits one row. + /// The full message stays in the tooltip. + static func firstClause(of message: String?) -> String? { + guard let message else { return nil } + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let head = trimmed.components(separatedBy: CharacterSet(charactersIn: ".:\n")).first ?? trimmed + let clause = head.trimmingCharacters(in: .whitespaces) + return clause.isEmpty ? trimmed : clause + } + +``` + +and add this factory next to `disabledItem(titled:)` at the bottom of the class: + +```swift + private func actionableItem() -> NSMenuItem { + let item = NSMenuItem(title: "", action: clickAction, keyEquivalent: "") + item.target = clickTarget + return item + } +``` + +- [ ] **Step 9: Run the tests and watch them pass** + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceSectionTests +``` + +Expected output (tail): + +``` +Test Case '-[MCPProxyTests.GlanceSectionTests testActivityRowCarriesFullIdentity]' passed (0.049 seconds). +Test Suite 'GlanceSectionTests' passed at ... + Executed 9 tests, with 0 failures (0 unexpected) in 0.045 (0.050) seconds +``` + +- [ ] **Step 10: Commit the Recent section** + +```bash +cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift && git commit -m "feat(tray): glance Recent rows with full row identity" +``` + +- [ ] **Step 11: Add the Clients, histogram and full-layout tests** + +In `native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift`, insert this block immediately before the `// MARK: - Helpers` line: + +```swift + // MARK: - Clients section and histogram + + func testClientRowCarriesSessionIdentity() { + let section = Self.makeSection() + let client = section.items(for: Self.busyState(), now: Self.now)[8] + XCTAssertEqual(client.title, "Claude Code — 8 calls · 1m") + XCTAssertEqual(client.representedObject as? String, "sess-a") + XCTAssertEqual(client.toolTip, "Claude Code 2.1.0") + XCTAssertEqual(client.accessibilityLabel(), "Claude Code, 8 calls, last active 1m ago") + } + + func testNoClientsShowsOneMutedRow() { + let state = Self.busyState() + state.glanceSessions = [] + let section = Self.makeSection() + let row = section.items(for: state, now: Self.now)[8] + XCTAssertEqual(row.title, "No connected clients") + XCTAssertFalse(row.isEnabled) + } + + func testHistogramSubmenuShowsLoadingUntilUsageArrives() { + let section = Self.makeSection() + let histogram = section.items(for: Self.busyState(), now: Self.now)[10] + XCTAssertEqual(histogram.title, "Activity (24h)") + XCTAssertEqual(histogram.submenu?.item(at: 0)?.title, "Loading…") + } + + func testHistogramSubmenuUsesInjectedViewWhenAvailable() { + let state = Self.busyState() + state.usageTimeline = [UsageBucket(start: Self.now, calls: 12, errors: 1, totalRespBytes: 0)] + let section = Self.makeSection() + section.histogramViewBuilder = { buckets in + let view = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: 90)) + view.setAccessibilityLabel("\(buckets.count) buckets") + return view + } + let chart = section.items(for: state, now: Self.now)[10].submenu?.item(at: 0) + XCTAssertNotNil(chart?.view) + XCTAssertEqual(chart?.view?.accessibilityLabel(), "1 buckets") + } + + func testHistogramSubmenuFallsBackToTextWithoutABuilder() { + let state = Self.busyState() + state.usageTimeline = [UsageBucket(start: Self.now, calls: 12, errors: 1, totalRespBytes: 0)] + let section = Self.makeSection() + let items = section.items(for: state, now: Self.now) + XCTAssertEqual(items[10].submenu?.item(at: 0)?.title, "12 calls · 1 error (24h)") + } + + func testBlockLayoutOrder() { + let section = Self.makeSection() + let items = section.items(for: Self.busyState(), now: Self.now) + let titles = items.map { $0.isSeparatorItem ? "—" : $0.title } + XCTAssertEqual(titles, [ + "12 calls this hour · 1 client", + "—", + "Recent", + "github:create_issue — 30s", + "jira:get_issue · auth failed — 2m", + "Open Activity…", + "—", + "Clients", + "Claude Code — 8 calls · 1m", + "—", + "Activity (24h)", + "—" + ]) + } + +``` + +- [ ] **Step 12: Run the tests and watch them fail** + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceSectionTests +``` + +Expected output — the injection seam does not exist yet: + +``` +/Users/user/repos/mcpproxy-go/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift:127:17: error: value of type 'GlanceSection' has no member 'histogramViewBuilder' + 127 | section.histogramViewBuilder = { buckets in + | `- error: value of type 'GlanceSection' has no member 'histogramViewBuilder' +error: fatalError +``` + +- [ ] **Step 13: Implement the Clients section and the histogram submenu item** + +In `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift` make four edits. + +(a) Add the injection seam — insert immediately after the `private let clickAction: Selector` line: + +```swift + + /// Builds the view for the histogram submenu's single custom item. While + /// this is nil the submenu falls back to a plain text line, which keeps the + /// component usable and testable without SwiftUI Charts. + var histogramViewBuilder: (([UsageBucket]) -> NSView)? +``` + +(b) Extend the owned-items block — replace + +```swift + private var summaryItem: NSMenuItem? + private var activityRows: [NSMenuItem] = [] +``` + +with + +```swift + private var summaryItem: NSMenuItem? + private var activityRows: [NSMenuItem] = [] + private var clientRows: [NSMenuItem] = [] + /// Held only so ownership of the submenu is explicit; `updateInPlace` + /// deliberately never touches it (re-creating it would disturb an open + /// submenu), so nothing reads this back. + private var histogramItem: NSMenuItem? +``` + +(c) In `items(for:now:)`, replace the reset lines + +```swift + summaryItem = nil + activityRows = [] + guard isVisible(for: state) else { return [] } +``` + +with + +```swift + summaryItem = nil + activityRows = [] + clientRows = [] + histogramItem = nil + guard isVisible(for: state) else { return [] } +``` + +and replace the tail of the same method + +```swift + items.append(openActivity) + + return items +``` + +with + +```swift + items.append(openActivity) + items.append(.separator()) + + items.append(disabledItem(titled: "Clients")) + let clients = GlanceSelection.activeClients(from: state.glanceSessions) + if clients.isEmpty { + items.append(disabledItem(titled: "No connected clients")) + } else { + for session in clients { + let row = actionableItem() + apply(session, to: row, now: now) + clientRows.append(row) + items.append(row) + } + } + items.append(.separator()) + + let histogram = makeHistogramItem(for: state) + histogramItem = histogram + items.append(histogram) + items.append(.separator()) + + return items +``` + +(d) Insert these two members immediately after the closing brace of `firstClause(of:)` — i.e. at the end of the `// MARK: Row rendering` section added in Step 8(c), so the two `apply` overloads stay adjacent and ahead of `// MARK: Header`: + +```swift + /// Rewrite a client row so it fully describes `session`. + private func apply(_ session: APIClient.MCPSession, to item: NSMenuItem, now: Date) { + let name = session.clientName.flatMap { $0.isEmpty ? nil : $0 } ?? "Unknown client" + let calls = session.toolCallCount ?? 0 + let callText = calls == 1 ? "1 call" : "\(calls) calls" + let age = session.lastActivity.map { GlanceFormatting.relativeTime($0, now: now) } + + if let age { + item.title = "\(name) — \(callText) · \(age)" + item.setAccessibilityLabel("\(name), \(callText), last active \(age) ago") + } else { + item.title = "\(name) — \(callText)" + item.setAccessibilityLabel("\(name), \(callText)") + } + + item.image = NSImage(systemSymbolName: "circle.fill", accessibilityDescription: "connected") + + if let version = session.clientVersion, !version.isEmpty { + item.toolTip = "\(name) \(version)" + } else { + item.toolTip = name + } + + item.representedObject = session.id + } + + // MARK: Histogram + + private func makeHistogramItem(for state: AppState) -> NSMenuItem { + let item = NSMenuItem(title: "Activity (24h)", action: nil, keyEquivalent: "") + let submenu = NSMenu() + + if let timeline = state.usageTimeline { + if let builder = histogramViewBuilder { + let chart = NSMenuItem(title: "", action: nil, keyEquivalent: "") + chart.view = builder(timeline) + submenu.addItem(chart) + } else { + let calls = timeline.reduce(0) { $0 + $1.calls } + let errors = timeline.reduce(0) { $0 + $1.errors } + let callText = calls == 1 ? "1 call" : "\(calls) calls" + let errorText = errors == 1 ? "1 error" : "\(errors) errors" + submenu.addItem(disabledItem(titled: "\(callText) · \(errorText) (24h)")) + } + } else { + submenu.addItem(disabledItem(titled: "Loading…")) + } + + item.submenu = submenu + return item + } + +``` + +(The fallback line reports the bucket totals as the endpoint defines them — a bucket's `calls` already includes its `errors`, so "12 calls · 1 error" means one of the twelve failed. The chart task, not this one, is where the `calls - errors` stacking correction applies.) + +- [ ] **Step 14: Run the tests and watch them pass** + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceSectionTests +``` + +Expected output (tail): + +``` +Test Case '-[MCPProxyTests.GlanceSectionTests testBlockLayoutOrder]' passed (0.001 seconds). +Test Suite 'GlanceSectionTests' passed at ... + Executed 15 tests, with 0 failures (0 unexpected) in 0.048 (0.052) seconds +``` + +- [ ] **Step 15: Commit the Clients section and histogram item** + +```bash +cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift && git commit -m "feat(tray): glance Clients rows and 24h histogram submenu item" +``` + +- [ ] **Step 16: Add the in-place update tests** + +In `native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift`, insert this block immediately before the `// MARK: - Helpers` line: + +```swift + // MARK: - In-place updates + + func testUpdateInPlaceRewritesTheEntireRowIdentity() { + let state = Self.busyState() + let section = Self.makeSection() + let items = section.items(for: state, now: Self.now) + let row = items[3] + + state.glanceActivity = [ + Self.entry(id: "c", server: "obsidian", tool: "search_notes", + timestamp: "2027-01-15T07:59:55Z", session: "sess-c"), + Self.entry(id: "b", server: "jira", tool: "get_issue", status: "error", + error: "auth failed: token expired. retry after refresh", + timestamp: "2027-01-15T07:58:00Z", session: "sess-b") + ] + state.callsThisHour = 13 + + XCTAssertTrue(section.updateInPlace(for: state, now: Self.now)) + XCTAssertEqual(items[0].title, "13 calls this hour · 1 client") + XCTAssertEqual(row.title, "obsidian:search_notes — 5s") + XCTAssertEqual(row.representedObject as? String, "sess-c", + "the click payload must follow the title, or the row opens the previous record's session") + XCTAssertEqual(row.image?.accessibilityDescription, "succeeded") + XCTAssertEqual(row.toolTip, "obsidian:search_notes") + XCTAssertEqual(row.accessibilityLabel(), "obsidian:search_notes, succeeded, 5s ago") + } + + func testUpdateInPlaceRefusesStructuralChange() { + let state = Self.busyState() + let section = Self.makeSection() + let items = section.items(for: state, now: Self.now) + + state.glanceActivity = [state.glanceActivity[0]] + + XCTAssertFalse(section.updateInPlace(for: state, now: Self.now), + "a row-count change must defer a rebuild, not mutate an open menu") + XCTAssertEqual(items[3].title, "github:create_issue — 30s", "rows must be left untouched") + } + + func testUpdateInPlaceRefusesWhenHistogramLoadednessFlips() { + let state = Self.busyState() + let section = Self.makeSection() + _ = section.items(for: state, now: Self.now) + + state.usageTimeline = [UsageBucket(start: Self.now, calls: 12, errors: 1, totalRespBytes: 0)] + + XCTAssertFalse(section.updateInPlace(for: state, now: Self.now)) + } + + func testUpdateInPlaceBeforeFirstBuildReportsStructural() { + XCTAssertFalse(Self.makeSection().updateInPlace(for: Self.busyState(), now: Self.now)) + } + +``` + +- [ ] **Step 17: Run the tests and watch them fail** + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceSectionTests +``` + +Expected output: + +``` +/Users/user/repos/mcpproxy-go/native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift:182:31: error: value of type 'GlanceSection' has no member 'updateInPlace' + 182 | XCTAssertTrue(section.updateInPlace(for: state, now: Self.now)) + | `- error: value of type 'GlanceSection' has no member 'updateInPlace' +error: fatalError +``` + +- [ ] **Step 18: Implement updateInPlace and the structure-tracking flags** + +In `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift` make three edits. + +(a) Extend the owned-items block — replace + +```swift + private var clientRows: [NSMenuItem] = [] +``` + +with + +```swift + private var clientRows: [NSMenuItem] = [] + + /// Snapshot of the structure the current items were built from, so an + /// in-place update can detect that a full rebuild is required instead. + private var hasBuilt = false + private var builtVisible = false + private var builtWithTimeline = false +``` + +(leave the `histogramItem` property and its comment where Step 13(b) put them — it follows this block). + +(b) In `items(for:now:)`, replace the reset lines + +```swift + summaryItem = nil + activityRows = [] + clientRows = [] + histogramItem = nil + guard isVisible(for: state) else { return [] } +``` + +with + +```swift + summaryItem = nil + activityRows = [] + clientRows = [] + histogramItem = nil + hasBuilt = true + builtVisible = isVisible(for: state) + builtWithTimeline = state.usageTimeline != nil + guard builtVisible else { return [] } +``` + +(c) Insert this method immediately after the closing brace of `items(for:now:)`, before `// MARK: Row rendering`: + +```swift + // MARK: In-place updates + + /// Rewrite the existing rows from `state` without restructuring the menu. + /// + /// Returns `true` when every row was updated in place, and `false` when the + /// block's structure changed (visibility, row count, or histogram + /// loaded-ness) — the caller must then defer a full rebuild until the menu + /// closes rather than growing or shrinking a menu the user is reading. + /// + /// A row's *entire identity* is rewritten, not just its title: with a fixed + /// number of rows every new event shifts which record each row represents, + /// so refreshing only the text would leave a row whose click still opened + /// the previous record's session. The histogram submenu is deliberately not + /// touched — re-creating it would disturb an open submenu — so a change in + /// its loaded-ness reports structural instead. + @discardableResult + func updateInPlace(for state: AppState, now: Date = Date()) -> Bool { + guard hasBuilt else { return false } + guard isVisible(for: state) == builtVisible else { return false } + guard builtVisible else { return true } + guard (state.usageTimeline != nil) == builtWithTimeline else { return false } + + let entries = GlanceSelection.activityRows(from: state.glanceActivity) + let clients = GlanceSelection.activeClients(from: state.glanceSessions) + guard entries.count == activityRows.count, + clients.count == clientRows.count else { return false } + + summaryItem?.title = summaryTitle(for: state) + for (row, entry) in zip(activityRows, entries) { apply(entry, to: row, now: now) } + for (row, session) in zip(clientRows, clients) { apply(session, to: row, now: now) } + return true + } + +``` + +- [ ] **Step 19: Run the tests and watch them pass** + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceSectionTests +``` + +Expected output (tail): + +``` +Test Case '-[MCPProxyTests.GlanceSectionTests testUpdateInPlaceRewritesTheEntireRowIdentity]' passed (0.001 seconds). +Test Suite 'GlanceSectionTests' passed at ... + Executed 19 tests, with 0 failures (0 unexpected) in 0.050 (0.056) seconds +``` + +- [ ] **Step 20: Run the whole native test suite to prove nothing else regressed** + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test +``` + +Expected output (tail) — every existing suite plus the 19 new tests, zero failures. A verification run of this exact task on top of its prerequisites executed **342** tests; the count moves as sibling tasks land, so treat "0 failures" as the assertion and the number as informational. This is exactly what the `swift-test` job in `.github/workflows/native-tests.yml` runs (`working-directory: native/macos/MCPProxy`, `run: swift test`, no `--skip`). + +``` +Test Suite 'All tests' passed at ... + Executed 342 tests, with 0 failures (0 unexpected) in ... seconds +✔ Test run with 0 tests in 0 suites passed after 0.001 seconds. +``` + +(If `swift test` fails with `precompiled file … was compiled with module cache path …`, the `.build` tree was copied in from another directory — `rm -rf .build/arm64-apple-macosx/debug/ModuleCache` and re-run.) + +- [ ] **Step 21: Commit the in-place update path** + +```bash +cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift native/macos/MCPProxy/MCPProxyTests/GlanceSectionTests.swift && git commit -m "feat(tray): in-place glance row updates with structural-change guard" +``` + +--- + +### Task 7: Swift — MCPProxyApp integration, open-menu rebuild guard, session deep link, TrayMenu.swift deletion + +**Files:** +- Create: `native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift` +- Create: `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceLinks.swift` +- Modify: `native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift` (stored properties, lines 21–25; `menuWillOpen` / NSMenuDelegate section, lines 190–197; `rebuildMenu()` head, lines 523–532; the separator before "Needs Attention", line 591; new action after `openWebUI()`, lines 996–998) +- Delete: `native/macos/MCPProxy/MCPProxy/Menu/TrayMenu.swift` (511 lines, dead — nothing constructs `TrayMenu`; it declares exactly one top-level symbol, `struct TrayMenu: View` at line 9, and its three helpers were salvaged into `Menu/Glance/GlanceFormatting.swift` in Task 5) +- Test: `native/macos/MCPProxy/MCPProxyTests/MenuRebuildGuardTests.swift` (create) +- Test: `native/macos/MCPProxy/MCPProxyTests/GlanceLinksTests.swift` (create) + +**Interfaces:** + +*Consumes* (must already exist when this task starts): +- From Task 6 — `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift`: + ```swift + struct GlanceSection { + init(target: AnyObject?, openActivityAction: Selector) + /// Plain NSMenuItems (activity rows, client rows, separators) plus the + /// "Activity (24h)" submenu item. Returns [] when the core is not + /// connected. Reads AppState only — issues no network request. + func items(for state: AppState) -> [NSMenuItem] + } + ``` +- From Task 2 — `native/macos/MCPProxy/MCPProxy/State/AppState.swift`: `@Published var glanceActivity: [ActivityEntry]`, `@Published var glanceSessions: [APIClient.MCPSession]`, `@Published var usageTimeline: [UsageBucket]?`, `@Published var callsThisHour: Int?`. +- Already in the repo (verified): `AppController` in `MCPProxyApp.swift` already conforms to `NSMenuDelegate` (line 15) and sets `menu.delegate = self` (line 530); `CoreProcessManager.currentAPIKey` is `nonisolated var currentAPIKey: String? { get async }` (`Core/CoreProcessManager.swift:76-79`); `AppState.webUIBaseURL` is `@Published var webUIBaseURL: String = "http://127.0.0.1:8080"` (`State/AppState.swift:131`). + +*Produces* (later tasks and the QA pass rely on these exact symbols): +- `enum MenuRebuildDecision: Equatable { case rebuild, updateInPlace, deferUntilClose }` +- `struct MenuRebuildGuard` with `var isMenuOpen: Bool { get }`, `var isDirty: Bool { get }`, `mutating func menuWillOpen()`, `mutating func decide(structureChanged: Bool) -> MenuRebuildDecision`, `mutating func menuDidClose() -> Bool` +- `func glanceRowsAreCompatible(installed: [NSMenuItem], fresh: [NSMenuItem]) -> Bool` +- `func applyGlanceRowsInPlace(installed: [NSMenuItem], fresh: [NSMenuItem])` +- `func activityURLString(baseURL: String, apiKey: String, sessionID: String?) -> String` +- `AppController.openActivityForSession(_:)` — the `@objc` selector `GlanceSection` rows must target (`#selector(openActivityForSession(_:))`), reading the session id from `NSMenuItem.representedObject as? String` +- `AppController.menuDidClose(_:)` — new `NSMenuDelegate` method. Both it and the existing `menuWillOpen(_:)` gain a `menu === statusItem.menu` guard so submenu open/close never drives the rebuild guard (see Step 15). + +--- + +Every `swift build` / `swift test` invocation in this repo prints this harmless preamble first; ignore it: + +``` +warning: 'mcpproxy': found 4 file(s) which are unhandled; explicitly declare them as resources or exclude from the target + .../MCPProxy/MCPProxy.entitlements + .../MCPProxy/Assets.xcassets + .../MCPProxy/Info.plist + .../MCPProxy/mcpproxy.icns +``` + +- [ ] **Step 1: Write the failing rebuild-guard test** + + Create `native/macos/MCPProxy/MCPProxyTests/MenuRebuildGuardTests.swift` with exactly this content: + + ```swift + import XCTest + import AppKit + @testable import MCPProxy + + final class MenuRebuildGuardTests: XCTestCase { + + func testClosedMenuAlwaysRebuilds() { + var guardState = MenuRebuildGuard() + XCTAssertEqual(guardState.decide(structureChanged: false), .rebuild) + XCTAssertEqual(guardState.decide(structureChanged: true), .rebuild) + XCTAssertFalse(guardState.isDirty) + } + + func testOpenMenuWithSameStructureUpdatesInPlace() { + var guardState = MenuRebuildGuard() + guardState.menuWillOpen() + XCTAssertEqual(guardState.decide(structureChanged: false), .updateInPlace) + XCTAssertEqual(guardState.decide(structureChanged: false), .updateInPlace) + XCTAssertFalse(guardState.isDirty, "In-place updates must not owe a rebuild") + XCTAssertFalse(guardState.menuDidClose(), "No rebuild is owed after in-place updates only") + } + + func testStructuralChangeWhileOpenIsDeferredAndRunsOnceOnClose() { + var guardState = MenuRebuildGuard() + guardState.menuWillOpen() + XCTAssertEqual(guardState.decide(structureChanged: true), .deferUntilClose) + XCTAssertEqual(guardState.decide(structureChanged: true), .deferUntilClose) + XCTAssertTrue(guardState.isDirty) + + XCTAssertTrue(guardState.menuDidClose(), "The deferred rebuild must run on close") + XCTAssertFalse(guardState.isMenuOpen) + XCTAssertFalse(guardState.menuDidClose(), "The deferred rebuild runs exactly once") + } + + func testReopeningClearsAStaleDirtyFlag() { + var guardState = MenuRebuildGuard() + guardState.menuWillOpen() + _ = guardState.decide(structureChanged: true) + _ = guardState.menuDidClose() + guardState.menuWillOpen() + XCTAssertFalse(guardState.isDirty) + } + } + ``` + + No target registration is needed: `Package.swift` globs both targets by `path:` with no `sources:` or `exclude:`, so dropping the file into `MCPProxyTests/` is enough. + +- [ ] **Step 2: Run it and watch it fail to compile** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter MenuRebuildGuardTests + ``` + + Expected: the build stops before any test runs, with lines like + + ``` + .../MCPProxyTests/MenuRebuildGuardTests.swift:7:29: error: cannot find 'MenuRebuildGuard' in scope + var guardState = MenuRebuildGuard() + ^~~~~~~~~~~~~~~~ + error: fatalError + ``` + + (`Executed 0 tests` — nothing runs.) + +- [ ] **Step 3: Implement the guard** + + Create `native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift`: + + ```swift + // MenuRebuildGuard.swift + // MCPProxy + // + // Rebuild policy for the status-bar menu while it is on screen. + + import AppKit + + /// What a rebuild request is allowed to do at this moment. + enum MenuRebuildDecision: Equatable { + /// The menu is closed — clear it and build every item from scratch. + case rebuild + /// The menu is open and the new glance rows line up 1:1 with the installed + /// ones — rewrite them in place, add and remove nothing. + case updateInPlace + /// The menu is open and the structure changed — do nothing now, remember + /// that a rebuild is owed, and run it when the menu closes. + case deferUntilClose + } + + /// Tracks whether the status-bar menu is on screen and whether a structural + /// rebuild was suppressed while it was. + /// + /// Live SSE rows make the debounced `objectWillChange -> rebuildMenu()` sink + /// fire during active traffic, i.e. potentially while the user is reading the + /// menu. `removeAllItems()` under the cursor — a menu that grows or shrinks + /// mid-read, or an open submenu that collapses — is exactly the irritation the + /// glance design forbids, so structural churn waits for `menuDidClose`. + struct MenuRebuildGuard { + /// True between `menuWillOpen()` and `menuDidClose()`. + private(set) var isMenuOpen = false + + /// True when a structural rebuild was suppressed while the menu was open. + private(set) var isDirty = false + + /// Arm the guard. Call AFTER the pre-display rebuild in `menuWillOpen`. + mutating func menuWillOpen() { + isMenuOpen = true + isDirty = false + } + + /// Decide what a rebuild request may do. + /// - Parameter structureChanged: true when the new glance rows cannot be + /// written over the installed ones (different count or layout). + mutating func decide(structureChanged: Bool) -> MenuRebuildDecision { + guard isMenuOpen else { return .rebuild } + if structureChanged { + isDirty = true + return .deferUntilClose + } + return .updateInPlace + } + + /// Disarm the guard. Returns true when a rebuild was deferred and is owed. + mutating func menuDidClose() -> Bool { + isMenuOpen = false + let owed = isDirty + isDirty = false + return owed + } + } + ``` + +- [ ] **Step 4: Run the guard tests and watch them pass** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter MenuRebuildGuardTests + ``` + + Expected tail: + + ``` + Test Suite 'MenuRebuildGuardTests' passed at ... + Executed 4 tests, with 0 failures (0 unexpected) in 0.000 (0.000) seconds + ``` + +- [ ] **Step 5: Commit the guard** + + ```bash + cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift native/macos/MCPProxy/MCPProxyTests/MenuRebuildGuardTests.swift && git commit -m "feat(tray): add MenuRebuildGuard so an open menu never restructures" + ``` + +- [ ] **Step 6: Write the failing in-place row-update tests** + + Append this second test class to the end of `native/macos/MCPProxy/MCPProxyTests/MenuRebuildGuardTests.swift` (after the closing `}` of `MenuRebuildGuardTests`): + + ```swift + + final class GlanceRowInPlaceUpdateTests: XCTestCase { + + private func row(title: String, session: String?, symbol: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.representedObject = session + item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: symbol) + item.toolTip = "tip:\(title)" + item.setAccessibilityLabel("a11y:\(title)") + return item + } + + func testDifferentRowCountIsIncompatible() { + let installed = [row(title: "a", session: "s1", symbol: "checkmark.circle")] + let fresh = [ + row(title: "a", session: "s1", symbol: "checkmark.circle"), + row(title: "b", session: "s2", symbol: "checkmark.circle"), + ] + XCTAssertFalse(glanceRowsAreCompatible(installed: installed, fresh: fresh)) + } + + func testSeparatorLayoutChangeIsIncompatible() { + let installed = [row(title: "a", session: "s1", symbol: "checkmark.circle"), NSMenuItem.separator()] + let fresh = [row(title: "a", session: "s1", symbol: "checkmark.circle"), + row(title: "b", session: "s2", symbol: "checkmark.circle")] + XCTAssertFalse(glanceRowsAreCompatible(installed: installed, fresh: fresh)) + } + + func testSubmenuPresenceChangeIsIncompatible() { + let installed = [row(title: "Activity (24h)", session: nil, symbol: "chart.bar")] + let withSubmenu = row(title: "Activity (24h)", session: nil, symbol: "chart.bar") + withSubmenu.submenu = NSMenu() + XCTAssertFalse(glanceRowsAreCompatible(installed: installed, fresh: [withSubmenu])) + } + + /// The whole identity moves, not just the text: a row whose title says one + /// record while its click opens another is worse than a stale row. + func testInPlaceUpdateRewritesEntireRowIdentity() { + let installed = [row(title: "github:create_issue", session: "sess-1", symbol: "checkmark.circle")] + let fresh = [row(title: "jira:get_issue", session: "sess-2", symbol: "xmark.circle")] + fresh[0].isEnabled = true + + applyGlanceRowsInPlace(installed: installed, fresh: fresh) + + XCTAssertEqual(installed[0].title, "jira:get_issue") + XCTAssertEqual(installed[0].representedObject as? String, "sess-2") + XCTAssertEqual(installed[0].toolTip, "tip:jira:get_issue") + XCTAssertEqual(installed[0].accessibilityLabel(), "a11y:jira:get_issue") + XCTAssertEqual(installed[0].image?.accessibilityDescription, "xmark.circle") + } + + func testSubmenuIsNotReplacedByAnInPlaceUpdate() { + let installedItem = row(title: "Activity (24h)", session: nil, symbol: "chart.bar") + let installedSubmenu = NSMenu(title: "installed") + installedItem.submenu = installedSubmenu + + let freshItem = row(title: "Activity (24h)", session: nil, symbol: "chart.bar") + freshItem.submenu = NSMenu(title: "fresh") + + applyGlanceRowsInPlace(installed: [installedItem], fresh: [freshItem]) + + XCTAssertTrue(installedItem.submenu === installedSubmenu, + "Replacing the submenu would collapse it while the user has it open") + } + + func testIncompatibleRowsAreLeftUntouched() { + let installed = [row(title: "github:create_issue", session: "sess-1", symbol: "checkmark.circle")] + let fresh = [ + row(title: "jira:get_issue", session: "sess-2", symbol: "xmark.circle"), + row(title: "extra", session: "sess-3", symbol: "checkmark.circle"), + ] + applyGlanceRowsInPlace(installed: installed, fresh: fresh) + XCTAssertEqual(installed[0].title, "github:create_issue") + XCTAssertEqual(installed[0].representedObject as? String, "sess-1") + } + } + ``` + +- [ ] **Step 7: Run the new class and watch it fail to compile** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceRowInPlaceUpdateTests + ``` + + Expected: build failure, no tests executed, with + + ``` + .../MCPProxyTests/MenuRebuildGuardTests.swift:...: error: cannot find 'glanceRowsAreCompatible' in scope + .../MCPProxyTests/MenuRebuildGuardTests.swift:...: error: cannot find 'applyGlanceRowsInPlace' in scope + error: fatalError + ``` + +- [ ] **Step 8: Implement the two row functions** + + Append to the end of `native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift`: + + ```swift + + /// True when `fresh` rows can be written over `installed` ones without adding, + /// removing, or re-typing any menu item. + func glanceRowsAreCompatible(installed: [NSMenuItem], fresh: [NSMenuItem]) -> Bool { + guard installed.count == fresh.count else { return false } + for (old, new) in zip(installed, fresh) { + if old.isSeparatorItem != new.isSeparatorItem { return false } + if (old.submenu == nil) != (new.submenu == nil) { return false } + } + return true + } + + /// Rewrite `installed` glance rows from `fresh` ones, leaving the menu's + /// structure untouched. A no-op when the two are not compatible, so a caller + /// that mis-sequences the check cannot leave half-updated rows on screen. + /// + /// A row's ENTIRE identity is copied, not just its title: once five rows exist + /// every new event shifts which record each row represents, and refreshing only + /// the title would leave a row reading like the new record while its click still + /// opened the previous record's session — a wrong destination the user cannot + /// detect. `submenu` is deliberately NOT copied: replacing it would collapse the + /// histogram submenu while the user has it open. + func applyGlanceRowsInPlace(installed: [NSMenuItem], fresh: [NSMenuItem]) { + guard glanceRowsAreCompatible(installed: installed, fresh: fresh) else { return } + for (old, new) in zip(installed, fresh) where !old.isSeparatorItem { + old.title = new.title + old.attributedTitle = new.attributedTitle + old.image = new.image + old.toolTip = new.toolTip + old.representedObject = new.representedObject + old.target = new.target + old.action = new.action + old.isEnabled = new.isEnabled + old.setAccessibilityLabel(new.accessibilityLabel()) + } + } + ``` + +- [ ] **Step 9: Run both classes and watch them pass, then commit** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter "MenuRebuildGuardTests|GlanceRowInPlaceUpdateTests" + ``` + + Expected tail: + + ``` + Test Suite 'GlanceRowInPlaceUpdateTests' passed at ... + Executed 6 tests, with 0 failures (0 unexpected) in 0.053 (0.054) seconds + Test Suite 'MenuRebuildGuardTests' passed at ... + Executed 4 tests, with 0 failures (0 unexpected) in 0.000 (0.000) seconds + ``` + + Then: + + ```bash + cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift native/macos/MCPProxy/MCPProxyTests/MenuRebuildGuardTests.swift && git commit -m "feat(tray): rewrite glance rows in place, carrying the whole row identity" + ``` + +- [ ] **Step 10: Write the failing activity-deep-link tests** + + Create `native/macos/MCPProxy/MCPProxyTests/GlanceLinksTests.swift`: + + ```swift + import XCTest + @testable import MCPProxy + + final class GlanceLinksTests: XCTestCase { + + func testSessionAndKeyAreBothAppended() { + XCTAssertEqual( + activityURLString(baseURL: "http://127.0.0.1:8080", apiKey: "k1", sessionID: "sess-42"), + "http://127.0.0.1:8080/ui/activity?session=sess-42&apikey=k1" + ) + } + + func testMissingKeyOmitsTheParameter() { + XCTAssertEqual( + activityURLString(baseURL: "http://127.0.0.1:8080", apiKey: "", sessionID: "sess-42"), + "http://127.0.0.1:8080/ui/activity?session=sess-42" + ) + } + + func testMissingSessionOpensTheUnfilteredLog() { + XCTAssertEqual( + activityURLString(baseURL: "http://127.0.0.1:8080", apiKey: "k1", sessionID: nil), + "http://127.0.0.1:8080/ui/activity?apikey=k1" + ) + XCTAssertEqual( + activityURLString(baseURL: "http://127.0.0.1:8080", apiKey: "", sessionID: ""), + "http://127.0.0.1:8080/ui/activity" + ) + } + + func testSessionIDIsPercentEncoded() { + let url = activityURLString(baseURL: "http://127.0.0.1:8080", apiKey: "", sessionID: "a b&c") + XCTAssertEqual(url, "http://127.0.0.1:8080/ui/activity?session=a%20b%26c") + XCTAssertNotNil(URL(string: url)) + } + + func testNonDefaultPortIsPreserved() { + XCTAssertEqual( + activityURLString(baseURL: "http://127.0.0.1:18080", apiKey: "k", sessionID: "s"), + "http://127.0.0.1:18080/ui/activity?session=s&apikey=k" + ) + } + } + ``` + + (No `import Foundation` is needed — `import XCTest` re-exports it, which is why `URL` and `Date` resolve in the existing test files that import XCTest alone.) + +- [ ] **Step 11: Run it and watch it fail to compile** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceLinksTests + ``` + + Expected: build failure, no tests executed, with + + ``` + .../MCPProxyTests/GlanceLinksTests.swift:7:13: error: cannot find 'activityURLString' in scope + error: fatalError + ``` + +- [ ] **Step 12: Implement the URL builder** + + Create `native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceLinks.swift`: + + ```swift + // GlanceLinks.swift + // MCPProxy + // + // Web UI deep links opened from glance rows. + + import Foundation + + /// Build the Web UI activity-log URL, optionally filtered by session. + /// + /// `?session=` is the query parameter the Activity view reads on mount + /// (frontend/src/views/Activity.vue:1334, `route.query.session`), and the Web + /// UI router is history-based (createWebHistory), so `/ui/activity` is a real + /// path rather than a fragment. `apikey` travels as a query parameter because a + /// browser cannot send the `X-API-Key` header — `/ui/` is the one surface that + /// accepts it, and the Web UI strips only `apikey` from the address bar on load + /// (services/api.ts:69-80), keeping `session`. + func activityURLString(baseURL: String, apiKey: String, sessionID: String?) -> String { + let path = baseURL + "/ui/activity" + var query: [URLQueryItem] = [] + if let sessionID, !sessionID.isEmpty { + query.append(URLQueryItem(name: "session", value: sessionID)) + } + if !apiKey.isEmpty { + query.append(URLQueryItem(name: "apikey", value: apiKey)) + } + guard var components = URLComponents(string: path) else { return path } + components.queryItems = query.isEmpty ? nil : query + return components.string ?? path + } + ``` + +- [ ] **Step 13: Run the link tests, watch them pass, commit** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter GlanceLinksTests + ``` + + Expected tail: + + ``` + Test Suite 'GlanceLinksTests' passed at ... + Executed 5 tests, with 0 failures (0 unexpected) in 0.001 (0.001) seconds + ``` + + Then: + + ```bash + cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceLinks.swift native/macos/MCPProxy/MCPProxyTests/GlanceLinksTests.swift && git commit -m "feat(tray): build the session-filtered Web UI activity link" + ``` + +- [ ] **Step 14: Add the glance stored properties to AppController** + + In `native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift`, replace lines 24–25: + + ```swift + private var cancellables = Set() + private var keyMonitor: Any? + ``` + + with: + + ```swift + private var cancellables = Set() + private var keyMonitor: Any? + + /// Tray Glance: builds the activity / clients / histogram rows. Rows call + /// back into this delegate so the Web UI key handling stays in one place. + private lazy var glance = GlanceSection( + target: self, + openActivityAction: #selector(openActivityForSession(_:)) + ) + + /// The glance rows currently installed in the menu, in menu order. Held so + /// a rebuild that lands while the menu is open can rewrite them in place. + private var installedGlanceRows: [NSMenuItem] = [] + + /// Suppresses structural rebuilds while the menu is on screen. + private var rebuildGuard = MenuRebuildGuard() + ``` + +- [ ] **Step 15: Arm the guard in `menuWillOpen` and add `menuDidClose`** + + In the same file, replace the `menuWillOpen` body at lines 192–197: + + ```swift + func menuWillOpen(_ menu: NSMenu) { + // Spec 048: dropped the per-click `client.servers()` fetch. appState + // is fed by SSE (spec 047), so it's already current within ~50 ms of + // the last upstream state change. Rebuild from in-memory state only. + rebuildMenu() + } + ``` + + with: + + ```swift + func menuWillOpen(_ menu: NSMenu) { + // Only the status-bar menu drives the rebuild guard. NSMenuDelegate + // callbacks are delivered for whichever menu holds the delegate, and the + // glance histogram submenu builds its chart lazily on open — so it needs + // a delegate too. Without this check, opening that submenu would run a + // full rebuild (removeAllItems) on a menu that is on screen, and would + // re-arm/disarm the guard under the parent menu: exactly the + // restructuring-while-open the design forbids. + guard menu === statusItem.menu else { return } + + // Spec 048: dropped the per-click `client.servers()` fetch. appState + // is fed by SSE (spec 047), so it's already current within ~50 ms of + // the last upstream state change. Rebuild from in-memory state only. + // + // The guard is armed AFTER this rebuild: AppKit calls menuWillOpen + // before the menu is drawn, so restructuring here is safe and hands the + // user fresh rows. Every rebuild from this point on happens under the + // cursor and must not add or remove items. + rebuildMenu() + rebuildGuard.menuWillOpen() + } + + func menuDidClose(_ menu: NSMenu) { + guard menu === statusItem.menu else { return } + + // Run the structural rebuild that was suppressed while the menu was up. + // Deferred to the next run-loop turn: AppKit is still tearing the menu + // down inside this callback, and mutating it here is not safe. + guard rebuildGuard.menuDidClose() else { return } + DispatchQueue.main.async { [weak self] in + self?.rebuildMenu() + } + } + ``` + +- [ ] **Step 16: Put the guard at the head of `rebuildMenu()`** + + Replace lines 523–524: + + ```swift + private func rebuildMenu() { + let menu: NSMenu + ``` + + with: + + ```swift + private func rebuildMenu() { + // Tray Glance: decide what we are allowed to do before touching the menu. + // Building the rows is pure — `items(for:)` reads AppState only and + // issues no request — so it is safe to do this on every rebuild, even + // one we are about to discard (spec 048: menu open costs zero network). + let freshGlanceRows = glance.items(for: appState) + let compatible = glanceRowsAreCompatible(installed: installedGlanceRows, fresh: freshGlanceRows) + switch rebuildGuard.decide(structureChanged: !compatible) { + case .updateInPlace: + applyGlanceRowsInPlace(installed: installedGlanceRows, fresh: freshGlanceRows) + return + case .deferUntilClose: + return + case .rebuild: + break + } + + let menu: NSMenu + ``` + +- [ ] **Step 17: Insert the glance rows between the status header and "Needs Attention"** + + Replace the separator + comment at lines 591–593: + + ```swift + menu.addItem(.separator()) + + // Needs Attention — only auth required, connection errors, quarantine (NOT disabled) + ``` + + with: + + ```swift + menu.addItem(.separator()) + + // Tray Glance — recent tool calls, connected clients, 24h histogram. + // Hidden entirely when the core is not connected: `items(for:)` returns + // [] and this loop adds nothing. + installedGlanceRows = freshGlanceRows + for row in freshGlanceRows { + menu.addItem(row) + } + + // Needs Attention — only auth required, connection errors, quarantine (NOT disabled) + ``` + +- [ ] **Step 18: Add the `openActivityForSession` action** + + In the same file, insert this immediately before `@objc private func openConfigFile() {` (line 998, right after `openWebUI()` ends at line 996): + + ```swift + /// Open the Web UI activity log filtered by a glance row's session. + /// + /// Reuses `openWebUI()`'s key path: `webUIBaseURL` is scheme/host/port only + /// and a first-time browser session needs the API key appended, which only + /// the core manager holds. A row with no session id (an empty-state row, or + /// a record the core never attributed) opens the unfiltered log. + @objc private func openActivityForSession(_ sender: NSMenuItem) { + let sessionID = sender.representedObject as? String + Task { + let apiKey = await coreManager?.currentAPIKey ?? "" + let baseURL = await MainActor.run { appState.webUIBaseURL } + let urlString = activityURLString(baseURL: baseURL, apiKey: apiKey, sessionID: sessionID) + if let url = URL(string: urlString) { + NSWorkspace.shared.open(url) + } + } + } + + ``` + +- [ ] **Step 19: Build the app target and watch it compile** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift build + ``` + + Expected tail — the load-bearing line is the last one; the `[n/m]` indices shift as earlier tasks add files, so do not match on them: + + ``` + [41/43] Linking MCPProxy + [42/43] Applying MCPProxy + Build complete! (3.13s) + ``` + +- [ ] **Step 20: Delete the dead `Menu/TrayMenu.swift` and prove nothing referenced it** + + ```bash + cd /Users/user/repos/mcpproxy-go && git rm native/macos/MCPProxy/MCPProxy/Menu/TrayMenu.swift && grep -rn "TrayMenu" native --include="*.swift" + ``` + + Expected: `git rm` prints `rm 'native/macos/MCPProxy/MCPProxy/Menu/TrayMenu.swift'`, and the grep prints **exactly one line and exits 0** — the header comment Task 5 left behind, which is prose, not a reference: + + ``` + native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceFormatting.swift:6:// Salvaged from the retired Menu/TrayMenu.swift. + ``` + + To prove there is no *code* reference, filter that one file out; this prints nothing and exits 1: + + ```bash + cd /Users/user/repos/mcpproxy-go && grep -rn "TrayMenu" native --include="*.swift" | grep -v "Menu/Glance/GlanceFormatting.swift:" + ``` + + (`Menu/TrayIcon.swift` is a separate, out-of-scope file and does not mention `TrayMenu`. `TrayMenu.swift` declared exactly one top-level symbol, `struct TrayMenu: View`.) + + Then confirm the target still builds without it: + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift build + ``` + + Expected: ends with `Build complete!`. + +- [ ] **Step 21: Run the whole Swift suite exactly as CI does** + + ```bash + cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test + ``` + + Expected tail (the test count grows as earlier tasks land — 344 was the pre-plan baseline; the load-bearing part is `0 failures`): + + ``` + Test Suite 'MCPProxyPackageTests.xctest' passed at ... + Executed 344 tests, with 0 failures (0 unexpected) in 0.084 (0.095) seconds + ✔ Test run with 0 tests in 0 suites passed after 0.001 seconds. + ``` + + **If the run dies with `error: fatalError` and 0 tests executed, check whether the failure is in this task's files before debugging them.** A known way for the whole test target to fail to compile is a `UsageBucket` that only has `init(from decoder:)` — a custom decoding initializer suppresses the memberwise init, so `AppStateGlanceTests.swift`'s `UsageBucket(start:calls:errors:totalRespBytes:)` call fails with `missing argument for parameter 'from' in call`. That belongs to Tasks 2–4 (the real `UsageBucket` must declare an explicit memberwise `init`), not to this one, but this step's gate cannot go green until it is fixed. + +- [ ] **Step 22: Commit the integration** + + `TrayMenu.swift`'s deletion was already staged by the `git rm` in Step 20 — do **not** pass that path to `git add`. After `git rm`, the path exists neither in the worktree nor in the index, so `git add` on it fails with `fatal: pathspec ... did not match any files` (exit 128) and would abort the `&&` chain before `git commit` ever runs. + + ```bash + cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift && git commit -m "feat(tray): render the glance section in the menu, guard rebuilds while it is open + +Inserts GlanceSection rows between the status header and Needs Attention, +suppresses structural rebuilds while the menu is on screen (rows update in +place, the deferred rebuild runs on menuDidClose), adds the session-filtered +Web UI deep link, and deletes the dead Menu/TrayMenu.swift whose helpers now +live in Menu/Glance/GlanceFormatting.swift." + ``` + + Verify both changes landed in the commit: + + ```bash + cd /Users/user/repos/mcpproxy-go && git show --stat --name-status HEAD + ``` + + Expected: an `M` line for `native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift` and a `D` line for `native/macos/MCPProxy/MCPProxy/Menu/TrayMenu.swift`. + +--- + +### Task 8: Swift — `ActivityHistogramView` in the "Activity (24h)" submenu + +The 24-hour calls-per-hour bar chart. It is the only SwiftUI in the tray menu, hosted in the submenu's single custom item and built when the submenu opens. It renders from `AppState.usageTimeline` and issues **no** network request of its own (spec-048 invariant). + +Three correctness traps this task exists to avoid: + +1. A `UsageTimeBucket`'s `calls` field **already includes** its `errors`. The type is declared at `internal/contracts/types.go:406`, but the relationship is only visible in the aggregator: `internal/runtime/usage_aggregate.go:237-239` runs `b.Calls++` unconditionally and `b.Errors++` only `if rec.Status == "error"`. Stacking the raw fields draws every failure twice. The two segments must be `calls - errors` and `errors`. +2. The endpoint returns **only hours that exist** — the timeline is sparse. Missing hours must be synthesised as zero so the axis is a stable 24 hours instead of a jumping, variable-width chart. +3. A bar chart is opaque to VoiceOver, so the hosted item carries one accessibility label summarising the whole series (total calls, peak hour, error count). + +**Files:** + +- Create: `native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift` +- Create (test): `native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift` +- Modify: `native/macos/MCPProxy/MCPProxy/State/AppState.swift` — add `usageError` beside the existing usage fields (lines 102–105 once the AppState-fields task has landed), clear it in `updateUsage` (lines 341–347), clear it in `clearGlanceState` (lines 349–357), add `recordUsageFailure(_:)` after `updateUsage`. **None of these symbols exist in committed `HEAD`** — they arrive with the AppState-fields task listed under *Consumes*, and the line numbers above describe the file only after that task commits. Every step below therefore anchors on declaration text, not on a line number; treat the numbers as orientation only. + +No `Package.swift` change is needed. Both targets glob their directory (`path: "MCPProxy"` / `path: "MCPProxyTests"`), so a new file in any subdirectory — including `Menu/Glance/` — is compiled with no registration. Verified: `swift package describe` lists `Menu/Glance/ActivityHistogramView.swift` in the `MCPProxy` target. + +**Interfaces:** + +*Consumes* (must already exist and be committed before this task starts): + +```swift +// From the API-models task, in MCPProxy/API/UsageStub.swift. Note `Int`, not +// `Int64`; a SYNTHESIZED memberwise init (no custom `init` in the struct body); +// and `Codable` conformance in an extension, so the memberwise init stays +// internal-visible to the test target. +struct UsageBucket: Equatable { + let start: Date + let calls: Int + let errors: Int + let totalRespBytes: Int +} + +extension UsageBucket: Codable { + enum CodingKeys: String, CodingKey { + case start, calls, errors + case totalRespBytes = "total_resp_bytes" + } +} + +// From the AppState-fields task, on `final class AppState: ObservableObject`: +@Published var usageTimeline: [UsageBucket]? // nil == "not loaded yet" +@Published var callsThisHour: Int? +@MainActor func updateUsage(timeline: [UsageBucket], now: Date = Date()) +func clearGlanceState() // deliberately NOT @MainActor +static func floorToHour(_ date: Date) -> Date +``` + +*Produces* (later tasks — the menu wiring and the usage-refresh loop — rely on exactly these): + +```swift +struct HistogramBar: Identifiable, Equatable { + let hourStart: Date + let succeeded: Int + let errors: Int + var id: Date { hourStart } + var total: Int { succeeded + errors } +} + +enum HistogramState: Equatable { + case loading + case failed(String) + case loaded([HistogramBar]) +} + +enum ActivityHistogram { + static let hourCount: Int // 24 + static let chartItemSize: NSSize // 288 x 112 — the hosting view's measured fittingSize + static func floorToHour(_ date: Date) -> Date // delegates to AppState.floorToHour + static func bars(from timeline: [UsageBucket], now: Date) -> [HistogramBar] + static func state(timeline: [UsageBucket]?, errorMessage: String?, now: Date) -> HistogramState + static func accessibilitySummary(bars: [HistogramBar], timeZone: TimeZone = .current) -> String + static func chartMenuItem(bars: [HistogramBar]) -> NSMenuItem +} + +struct ActivityHistogramView: View { + let bars: [HistogramBar] + let accessibilitySummary: String +} + +final class ActivityHistogramSubmenu: NSObject, NSMenuDelegate { + let menuItem: NSMenuItem // insert THIS into the tray menu + init(appState: AppState, + now: @escaping () -> Date = Date.init, + chartItemFactory: @escaping ([HistogramBar]) -> NSMenuItem = ActivityHistogram.chartMenuItem) + func menuNeedsUpdate(_ menu: NSMenu) + func currentItem() -> NSMenuItem + static func mutedItem(_ title: String) -> NSMenuItem +} + +// Added to AppState by this task: +@Published var usageError: String? +@MainActor func recordUsageFailure(_ message: String) +``` + +**Deliberate extension of the design doc.** `docs/superpowers/specs/2026-07-29-tray-glance-design.md` names four new `AppState` fields (line 46) and its States table (lines 139–144) covers only "not loaded yet" and "loaded but idle". `usageError` / `HistogramState.failed` are a fifth field and a third state, added under the doc's own second constraint ("never lie"): without them a permanently failing refresh sits on "Loading…" forever, which is exactly the quiet lie the design forbids. Flagging it here so review reads it as an argued addition, not scope creep. + +--- + +- [ ] **Step 1: Write the failing bucket-shaping tests** + +Create `native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift` with exactly this content: + +```swift +import XCTest +import AppKit +@testable import MCPProxy + +/// Shared anchors. All times are UTC: the backend aligns buckets with +/// `rec.Timestamp.UTC().Truncate(time.Hour)`, so the tests use the same grid. +/// A top-level `private` declaration is visible to the whole file, so every +/// test class below shares these. +private enum Fixture { + /// 2027-01-15 08:35:00 UTC — deliberately mid-hour, so flooring is exercised. + static let now = Date(timeIntervalSince1970: 1_800_002_100) + /// 2027-01-15 08:00:00 UTC — the hour containing `now`; the newest bar. + static let currentHour = Date(timeIntervalSince1970: 1_800_000_000) + /// 2027-01-15 04:00:00 UTC — four hours back; bar index 19 on a 24-bar axis. + static let fourHoursAgo = Date(timeIntervalSince1970: 1_799_985_600) + /// 2027-01-14 09:00:00 UTC — the oldest bar on the axis. + static let oldestHour = Date(timeIntervalSince1970: 1_799_917_200) + /// 2027-01-13 06:00:00 UTC — 27 hours before the oldest bar, well off the + /// left edge of the axis. + static let offAxis = Date(timeIntervalSince1970: 1_799_820_000) + + static let utc = TimeZone(identifier: "UTC")! + + static func bucket(start: Date, calls: Int, errors: Int) -> UsageBucket { + UsageBucket(start: start, calls: calls, errors: errors, totalRespBytes: 0) + } +} + +final class ActivityHistogramBarsTests: XCTestCase { + + /// The usage endpoint omits hours with no activity, so the timeline is + /// sparse. The axis must still be a stable 24 hours, oldest first, ending + /// at the hour containing `now`. + func testMissingHoursAreSynthesisedAsZero() { + let timeline = [ + // 08:20 UTC — must land in the 08:00 bucket, not its own. + Fixture.bucket(start: Date(timeIntervalSince1970: 1_800_001_200), calls: 10, errors: 3), + Fixture.bucket(start: Fixture.fourHoursAgo, calls: 4, errors: 0) + ] + + let bars = ActivityHistogram.bars(from: timeline, now: Fixture.now) + + XCTAssertEqual(bars.count, 24) + XCTAssertEqual(bars.first?.hourStart, Fixture.oldestHour) + XCTAssertEqual(bars.last?.hourStart, Fixture.currentHour) + XCTAssertEqual(bars[19].hourStart, Fixture.fourHoursAgo) + XCTAssertEqual(bars[19].succeeded, 4) + XCTAssertEqual(bars[19].errors, 0) + XCTAssertEqual(bars.filter { $0.total > 0 }.count, 2, "every other hour is a synthesised zero") + } + + /// A bucket's `calls` ALREADY includes its `errors`. The two stacked + /// segments must therefore sum to `calls`, never to `calls + errors`. + func testStackedSegmentsDoNotDoubleCountErrors() { + let timeline = [Fixture.bucket(start: Fixture.currentHour, calls: 10, errors: 3)] + + let bars = ActivityHistogram.bars(from: timeline, now: Fixture.now) + + XCTAssertEqual(bars[23].succeeded, 7) + XCTAssertEqual(bars[23].errors, 3) + XCTAssertEqual(bars[23].total, 10) + } + + /// Defensive: a bucket claiming more errors than calls must not produce a + /// negative segment — Charts would draw it below the axis. + func testErrorsExceedingCallsClampSucceededToZero() { + let timeline = [Fixture.bucket(start: Fixture.currentHour, calls: 2, errors: 5)] + + let bars = ActivityHistogram.bars(from: timeline, now: Fixture.now) + + XCTAssertEqual(bars[23].succeeded, 0) + XCTAssertEqual(bars[23].errors, 5) + } + + /// Buckets older than the axis are dropped, not folded into the oldest bar + /// (which would make yesterday's spike look like this morning's). + func testBucketsOlderThanTheAxisAreDropped() { + let timeline = [Fixture.bucket(start: Fixture.offAxis, calls: 99, errors: 9)] + + let bars = ActivityHistogram.bars(from: timeline, now: Fixture.now) + + XCTAssertEqual(bars.reduce(0) { $0 + $1.total }, 0) + } +} +``` + +- [ ] **Step 2: Run the new tests and watch them fail** + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter ActivityHistogramBarsTests 2>&1 | tail -20 +``` + +The test target fails to **compile** — no test executes. Expect lines like: + +``` +.../MCPProxyTests/ActivityHistogramTests.swift:44:20: error: cannot find 'ActivityHistogram' in scope + let bars = ActivityHistogram.bars(from: timeline, now: Fixture.now) + `- error: cannot find 'ActivityHistogram' in scope +error: fatalError +``` + +If instead you see `cannot find 'UsageBucket' in scope`, the API-models task listed under *Consumes* has not landed — stop and resolve that first. Likewise, if step 13's tests later report `AppState` has no `floorToHour`, the AppState-fields task has not landed. + +- [ ] **Step 3: Create the file with the bar model and the bucket shaping** + +Create `native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift`: + +```swift +// ActivityHistogramView.swift +// MCPProxy +// +// The 24-hour calls-per-hour bar chart shown in the tray glance's +// "Activity (24h)" submenu, plus the pure bucket-shaping and accessibility +// helpers it renders from. +// +// The chart renders from `AppState.usageTimeline` only — opening the submenu +// performs no network request (spec 048 invariant). + +import Foundation + +// MARK: - Bar model + +/// One hour of the 24-hour histogram, already split into the two stacked +/// segments the chart draws. +/// +/// A `UsageBucket`'s `calls` ALREADY includes its `errors`, so stacking the raw +/// fields would draw every failure twice. `succeeded` is the difference. +struct HistogramBar: Identifiable, Equatable { + /// Start of the UTC hour this bar covers. + let hourStart: Date + /// Calls that did not fail: `calls - errors`, never negative. + let succeeded: Int + /// Calls that failed. + let errors: Int + + var id: Date { hourStart } + + /// Total calls in the hour — the height of the stacked bar. + var total: Int { succeeded + errors } +} + +// MARK: - Pure helpers + +/// Bucket shaping and accessibility copy for the 24-hour histogram. +/// Pure and synchronous, so it is testable without AppKit or a window server. +enum ActivityHistogram { + + /// Bars on the axis. Fixed, so the axis does not resize as traffic starts + /// and stops. + static let hourCount = 24 + + /// Truncate a date to the start of its UTC hour. + /// + /// Delegates to `AppState.floorToHour` rather than reimplementing it: the + /// header count (`AppState.callsInCurrentHour`) and this axis must agree on + /// where an hour begins, and two copies of the rule would be free to drift. + static func floorToHour(_ date: Date) -> Date { + AppState.floorToHour(date) + } + + /// Project a sparse timeline onto a dense 24-hour axis ending at the UTC + /// hour containing `now`, oldest hour first. + /// + /// The endpoint returns only hours that exist, so missing hours are + /// synthesised as zero and buckets older than the axis are dropped. + static func bars(from timeline: [UsageBucket], now: Date) -> [HistogramBar] { + var succeededByHour: [Date: Int] = [:] + var errorsByHour: [Date: Int] = [:] + + for bucket in timeline { + let hour = floorToHour(bucket.start) + let errors = max(0, bucket.errors) + // `calls` includes `errors`; clamp so a malformed bucket where + // errors > calls cannot produce a negative segment. + let succeeded = max(0, bucket.calls - errors) + succeededByHour[hour, default: 0] += succeeded + errorsByHour[hour, default: 0] += errors + } + + let currentHour = floorToHour(now) + return (0..&1 | tail -12 +``` + +Expect (first run rebuilds the tray target, ~20 s): + +``` +Test Case '-[MCPProxyTests.ActivityHistogramBarsTests testBucketsOlderThanTheAxisAreDropped]' passed (0.001 seconds). +Test Case '-[MCPProxyTests.ActivityHistogramBarsTests testErrorsExceedingCallsClampSucceededToZero]' passed (0.000 seconds). +Test Case '-[MCPProxyTests.ActivityHistogramBarsTests testMissingHoursAreSynthesisedAsZero]' passed (0.000 seconds). +Test Case '-[MCPProxyTests.ActivityHistogramBarsTests testStackedSegmentsDoNotDoubleCountErrors]' passed (0.000 seconds). +Executed 4 tests, with 0 failures (0 unexpected) in 0.001 (0.002) seconds +``` + +- [ ] **Step 5: Commit the bucket shaping** + +```bash +cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift && git commit -m "feat(tray): shape sparse usage buckets onto a dense 24h histogram axis + +A UsageBucket's calls field already includes its errors, so the stacked +segments are calls-errors and errors. Hours the endpoint omits are +synthesised as zero to keep the axis a stable 24 hours." +``` + +- [ ] **Step 6: Write the failing accessibility-summary tests** + +Append to `native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift`: + +```swift +final class ActivityHistogramAccessibilityTests: XCTestCase { + + /// A bar chart is opaque to VoiceOver, so the hosted item carries one + /// sentence describing the whole series. + func testSummaryReportsTotalsAndPeakHour() { + let timeline = [ + Fixture.bucket(start: Fixture.currentHour, calls: 10, errors: 3), + Fixture.bucket(start: Fixture.fourHoursAgo, calls: 4, errors: 0) + ] + let bars = ActivityHistogram.bars(from: timeline, now: Fixture.now) + + let summary = ActivityHistogram.accessibilitySummary(bars: bars, timeZone: Fixture.utc) + + XCTAssertEqual( + summary, + "Activity over the last 24 hours: 14 calls, 3 errors. Busiest hour 08:00 with 10 calls." + ) + } + + /// "Loaded but idle" must read as idle, not as a broken chart. + func testSummaryForAnIdleTimelineSaysSo() { + let bars = ActivityHistogram.bars(from: [], now: Fixture.now) + + XCTAssertEqual( + ActivityHistogram.accessibilitySummary(bars: bars, timeZone: Fixture.utc), + "Activity over the last 24 hours: no tool calls." + ) + } +} +``` + +- [ ] **Step 7: Run the accessibility tests and watch them fail** + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter ActivityHistogramAccessibilityTests 2>&1 | tail -12 +``` + +Compile failure, no test executes: + +``` +.../MCPProxyTests/ActivityHistogramTests.swift:NN:44: error: type 'ActivityHistogram' has no member 'accessibilitySummary' +error: fatalError +``` + +- [ ] **Step 8: Implement `accessibilitySummary`** + +In `native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift`, insert this method inside `enum ActivityHistogram`, immediately after the closing brace of `bars(from:now:)` and before the enum's own closing brace: + +```swift + /// One sentence describing the whole series, because a bar chart is opaque + /// to VoiceOver. Ties on the peak resolve to the earliest hour. + /// + /// - Parameter timeZone: injected so the hour label is deterministic in + /// tests; production uses the user's zone. + static func accessibilitySummary(bars: [HistogramBar], timeZone: TimeZone = .current) -> String { + let totalCalls = bars.reduce(0) { $0 + $1.total } + let totalErrors = bars.reduce(0) { $0 + $1.errors } + guard let first = bars.first, totalCalls > 0 else { + return "Activity over the last 24 hours: no tool calls." + } + + var peak = first + for bar in bars where bar.total > peak.total { peak = bar } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone + formatter.dateFormat = "HH:mm" + + return "Activity over the last 24 hours: \(totalCalls) calls, \(totalErrors) errors. " + + "Busiest hour \(formatter.string(from: peak.hourStart)) with \(peak.total) calls." + } +``` + +- [ ] **Step 9: Run the accessibility tests and watch them pass** + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter ActivityHistogramAccessibilityTests 2>&1 | tail -8 +``` + +Expect: + +``` +Test Case '-[MCPProxyTests.ActivityHistogramAccessibilityTests testSummaryForAnIdleTimelineSaysSo]' passed (0.001 seconds). +Test Case '-[MCPProxyTests.ActivityHistogramAccessibilityTests testSummaryReportsTotalsAndPeakHour]' passed (0.000 seconds). +Executed 2 tests, with 0 failures (0 unexpected) in 0.001 (0.002) seconds +``` + +- [ ] **Step 10: Commit the accessibility summary** + +```bash +cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift && git commit -m "feat(tray): summarise the 24h histogram in one VoiceOver sentence + +Total calls, error count and peak hour, so the chart is not a silent +element for screen-reader users." +``` + +- [ ] **Step 11: Write the failing `usageError` tests** + +`usageTimeline == nil` currently means both "not loaded yet" and "the fetch failed" — the submenu cannot tell them apart, so a permanently failing refresh would show "Loading…" forever. Add a distinct signal. + +Append to `native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift`: + +```swift +@MainActor +final class AppStateUsageErrorTests: XCTestCase { + + func testRecordUsageFailureStoresTheMessage() { + let state = AppState() + + XCTAssertNil(state.usageError) + state.recordUsageFailure("connection refused") + XCTAssertEqual(state.usageError, "connection refused") + } + + /// A successful refresh must clear a stale failure, otherwise the submenu + /// would show the error row forever once a single fetch had failed. + func testUpdateUsageClearsAPreviousFailure() { + let state = AppState() + state.recordUsageFailure("connection refused") + + state.updateUsage( + timeline: [Fixture.bucket(start: Fixture.currentHour, calls: 1, errors: 0)], + now: Fixture.now + ) + + XCTAssertNil(state.usageError) + XCTAssertEqual(state.callsThisHour, 1) + } + + /// Disconnecting must not leave the previous core's failure on screen. + func testClearGlanceStateClearsTheFailure() { + let state = AppState() + state.recordUsageFailure("connection refused") + + state.clearGlanceState() + + XCTAssertNil(state.usageError) + } +} +``` + +- [ ] **Step 12: Run the `usageError` tests and watch them fail** + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter AppStateUsageErrorTests 2>&1 | tail -12 +``` + +Compile failure: + +``` +.../MCPProxyTests/ActivityHistogramTests.swift:NN:23: error: value of type 'AppState' has no member 'usageError' +.../MCPProxyTests/ActivityHistogramTests.swift:NN:15: error: value of type 'AppState' has no member 'recordUsageFailure' +error: fatalError +``` + +- [ ] **Step 13: Add `usageError` and `recordUsageFailure` to `AppState`** + +Three **additive** edits in `native/macos/MCPProxy/MCPProxy/State/AppState.swift`. Each is anchored on a declaration, not a line number, and none rewrites an existing method body — the AppState-fields task's `updateUsage` may guard its assignments (`if usageTimeline != timeline { usageTimeline = timeline }`) and a wholesale replacement would silently revert that. + +(a) Add the field. Find this declaration (around line 105) and add the new property directly below it: + +```swift + /// Calls recorded in the CURRENT UTC hour. `nil` means "not loaded yet". + @Published var callsThisHour: Int? +``` + +becomes: + +```swift + /// Calls recorded in the CURRENT UTC hour. `nil` means "not loaded yet". + @Published var callsThisHour: Int? + + /// Last usage-refresh failure, surfaced as a muted row in the histogram + /// submenu. `nil` means "no failure recorded"; the next successful refresh + /// clears it. Without this the submenu could not tell "still loading" from + /// "the fetch failed" — both leave `usageTimeline` nil, and a permanently + /// failing refresh would sit on "Loading…" forever. + @Published var usageError: String? +``` + +(b) Clear it on success and add the recorder. Inside `updateUsage(timeline:now:)`, find its last statement: + +```swift + if callsThisHour != calls { callsThisHour = calls } + } +``` + +and extend it to: + +```swift + if callsThisHour != calls { callsThisHour = calls } + if usageError != nil { usageError = nil } + } + + /// Record a failed usage refresh so the histogram submenu can say so + /// instead of showing "Loading…" forever. Called from the usage refresh's + /// catch block. + @MainActor + func recordUsageFailure(_ message: String) { + if usageError != message { usageError = message } + } +``` + +Leave every other line of `updateUsage` exactly as you found it. + +(c) Reset it on disconnect. In `clearGlanceState()`, add one line after the `callsThisHour` reset: + +```swift + if callsThisHour != nil { callsThisHour = nil } + if usageError != nil { usageError = nil } +``` + +- [ ] **Step 14: Run the `usageError` tests and watch them pass** + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter AppStateUsageErrorTests 2>&1 | tail -8 +``` + +Expect: + +``` +Test Case '-[MCPProxyTests.AppStateUsageErrorTests testClearGlanceStateClearsTheFailure]' passed (0.001 seconds). +Test Case '-[MCPProxyTests.AppStateUsageErrorTests testRecordUsageFailureStoresTheMessage]' passed (0.000 seconds). +Test Case '-[MCPProxyTests.AppStateUsageErrorTests testUpdateUsageClearsAPreviousFailure]' passed (0.000 seconds). +Executed 3 tests, with 0 failures (0 unexpected) in 0.001 (0.002) seconds +``` + +- [ ] **Step 15: Commit the failure signal** + +```bash +cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/State/AppState.swift native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift && git commit -m "feat(tray): distinguish a failed usage refresh from \"not loaded yet\" + +usageTimeline == nil meant both, so a permanently failing refresh would +show \"Loading…\" forever. usageError carries the message; a successful +refresh and a disconnect both clear it." +``` + +- [ ] **Step 16: Write the SwiftUI chart and the hosted menu item** + +Append to `native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift`, and change the file's `import Foundation` at the top to the three imports shown first (SwiftUI and AppKit both re-export Foundation, so `Date`/`TimeInterval` stay available): + +```swift +import SwiftUI +import Charts +import AppKit +``` + +```swift +// MARK: - Chart + +/// The stacked bar chart itself. Rendering only — it fetches nothing. +/// +/// Two `BarMark`s per hour sharing an x value stack automatically; the series +/// are `calls - errors` and `errors`, never the raw fields. +struct ActivityHistogramView: View { + let bars: [HistogramBar] + let accessibilitySummary: String + + var body: some View { + Chart { + ForEach(bars) { bar in + BarMark( + x: .value("Hour", bar.hourStart, unit: .hour), + y: .value("Calls", bar.succeeded) + ) + .foregroundStyle(by: .value("Outcome", "Succeeded")) + + BarMark( + x: .value("Hour", bar.hourStart, unit: .hour), + y: .value("Calls", bar.errors) + ) + .foregroundStyle(by: .value("Outcome", "Errors")) + } + } + .chartForegroundStyleScale([ + "Succeeded": Color.accentColor, + "Errors": Color.red + ]) + // The legend would double the item's height for two self-evident + // colours; the accessibility label names both series instead. + .chartLegend(.hidden) + .chartYAxis { + AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) + } + .chartXAxis { + AxisMarks(values: .stride(by: .hour, count: 6)) { _ in + AxisGridLine() + AxisValueLabel(format: .dateTime.hour()) + } + } + .frame(width: 260, height: 96) + .padding(.horizontal, 14) + .padding(.vertical, 8) + // One label for the whole chart: VoiceOver reading 48 unlabelled bar + // marks would be worse than useless. + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(accessibilitySummary)) + } +} + +extension ActivityHistogram { + + /// Size of the hosted chart item, in points. Menu items do not auto-size a + /// hosting view, so the frame is explicit — and it must match the view's + /// own size, or the row grows a band of dead space. 260 + 2*14 = 288 wide, + /// 96 + 2*8 = 112 tall; measured `NSHostingView.fittingSize` agrees. + static let chartItemSize = NSSize(width: 288, height: 112) + + /// The submenu's single custom item: an `NSHostingView` wrapping the chart. + /// + /// Custom menu-item views receive mouse events but not keyboard events, so + /// the item is disabled (nothing to activate) and carries the whole series + /// in one accessibility label on the host view. + static func chartMenuItem(bars: [HistogramBar]) -> NSMenuItem { + let summary = accessibilitySummary(bars: bars) + let item = NSMenuItem(title: "Activity (24h)", action: nil, keyEquivalent: "") + item.isEnabled = false + + let host = NSHostingView( + rootView: ActivityHistogramView(bars: bars, accessibilitySummary: summary) + ) + host.frame = NSRect(origin: .zero, size: chartItemSize) + host.setAccessibilityLabel(summary) + item.view = host + return item + } +} +``` + +- [ ] **Step 17: Build and confirm the Charts code compiles** + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift build 2>&1 | tail -5 +``` + +Expect: + +``` +[N/N] Compiling MCPProxy ActivityHistogramView.swift +[N/N] Emitting module MCPProxy +Build complete! (X.XXs) +``` + +`import Charts` needs no `@available` guard: `Package.swift` declares `platforms: [.macOS(.v13)]` and Swift Charts ships from macOS 13.0. + +- [ ] **Step 18: Commit the chart** + +```bash +cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift && git commit -m "feat(tray): SwiftUI Charts bar chart for the 24h activity histogram + +Two stacked BarMarks per hour hosted in an NSHostingView sized for a menu +item, with the series summary as the host view's accessibility label." +``` + +- [ ] **Step 19: Write the failing submenu tests** + +Append to `native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift`: + +```swift +@MainActor +final class ActivityHistogramSubmenuTests: XCTestCase { + + /// The chart item is stubbed so no test ever instantiates an + /// `NSHostingView`, which would want a window server. + private func makeSubmenu(_ state: AppState) -> ActivityHistogramSubmenu { + ActivityHistogramSubmenu( + appState: state, + now: { Fixture.now }, + chartItemFactory: { bars in + NSMenuItem(title: "CHART:\(bars.count)", action: nil, keyEquivalent: "") + } + ) + } + + /// Nothing is built until the submenu opens — that is the whole point of + /// hanging the chart off the submenu delegate. + func testSubmenuIsEmptyUntilItOpens() { + let submenu = makeSubmenu(AppState()) + + XCTAssertEqual(submenu.menuItem.title, "Activity (24h)") + XCTAssertEqual(submenu.menuItem.submenu?.numberOfItems, 0) + } + + func testLoadingRowWhileTheTimelineIsNil() { + let submenu = makeSubmenu(AppState()) + let menu = submenu.menuItem.submenu! + + submenu.menuNeedsUpdate(menu) + + XCTAssertEqual(menu.numberOfItems, 1) + XCTAssertEqual(menu.items[0].title, "Loading…") + XCTAssertFalse(menu.items[0].isEnabled) + let attributes = menu.items[0].attributedTitle!.attributes(at: 0, effectiveRange: nil) + XCTAssertEqual(attributes[.foregroundColor] as? NSColor, NSColor.secondaryLabelColor) + } + + func testErrorRowWhenTheFetchFailedBeforeAnyTimelineArrived() { + let state = AppState() + state.recordUsageFailure("connection refused") + let submenu = makeSubmenu(state) + let menu = submenu.menuItem.submenu! + + submenu.menuNeedsUpdate(menu) + + XCTAssertEqual(menu.numberOfItems, 1) + XCTAssertEqual(menu.items[0].title, "Usage unavailable") + XCTAssertEqual(menu.items[0].toolTip, "connection refused") + XCTAssertFalse(menu.items[0].isEnabled) + let attributes = menu.items[0].attributedTitle!.attributes(at: 0, effectiveRange: nil) + XCTAssertEqual(attributes[.foregroundColor] as? NSColor, NSColor.secondaryLabelColor) + } + + /// Real data beats a stale failure. + func testChartRowWinsOverAStaleFailure() { + let state = AppState() + state.recordUsageFailure("connection refused") + state.usageTimeline = [Fixture.bucket(start: Fixture.currentHour, calls: 3, errors: 1)] + let submenu = makeSubmenu(state) + let menu = submenu.menuItem.submenu! + + submenu.menuNeedsUpdate(menu) + + XCTAssertEqual(menu.numberOfItems, 1) + XCTAssertEqual(menu.items[0].title, "CHART:24") + } + + /// "Loaded but idle" is a flat 24-hour axis, deliberately distinct from the + /// loading row — and reopening replaces the row instead of appending. + func testReopeningReplacesTheRowAndAnIdleTimelineStillCharts() { + let state = AppState() + let submenu = makeSubmenu(state) + let menu = submenu.menuItem.submenu! + + submenu.menuNeedsUpdate(menu) + state.usageTimeline = [] + submenu.menuNeedsUpdate(menu) + + XCTAssertEqual(menu.numberOfItems, 1) + XCTAssertEqual(menu.items[0].title, "CHART:24") + } + + func testStateResolution() { + XCTAssertEqual( + ActivityHistogram.state(timeline: nil, errorMessage: nil, now: Fixture.now), + .loading + ) + XCTAssertEqual( + ActivityHistogram.state(timeline: nil, errorMessage: "", now: Fixture.now), + .loading, + "an empty message is not a failure" + ) + XCTAssertEqual( + ActivityHistogram.state(timeline: nil, errorMessage: "boom", now: Fixture.now), + .failed("boom") + ) + XCTAssertEqual( + ActivityHistogram.state(timeline: [], errorMessage: nil, now: Fixture.now), + .loaded(ActivityHistogram.bars(from: [], now: Fixture.now)) + ) + } +} +``` + +- [ ] **Step 20: Run the submenu tests and watch them fail** + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter ActivityHistogramSubmenuTests 2>&1 | tail -14 +``` + +Compile failure: + +``` +.../MCPProxyTests/ActivityHistogramTests.swift:NN:9: error: cannot find 'ActivityHistogramSubmenu' in scope +.../MCPProxyTests/ActivityHistogramTests.swift:NN:29: error: type 'ActivityHistogram' has no member 'state' +error: fatalError +``` + +- [ ] **Step 21: Implement `HistogramState`, `ActivityHistogram.state`, and the submenu controller** + +Two additions to `native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift`. + +(a) Insert the state enum immediately **above** `enum ActivityHistogram` (i.e. after the `HistogramBar` declaration): + +```swift +// MARK: - What the submenu shows + +/// What the histogram submenu renders right now. +/// +/// `loading` and `failed` are deliberately distinct: both leave the timeline +/// nil, and telling the user "Loading…" forever after a failed fetch is the +/// kind of quiet lie this menu must not tell. +enum HistogramState: Equatable { + /// No timeline yet, and no failure recorded. + case loading + /// The usage refresh failed before any timeline arrived; payload is the message. + case failed(String) + /// A full 24-hour axis, oldest hour first. An all-zero axis is a valid + /// loaded state — the proxy was simply idle. + case loaded([HistogramBar]) +} +``` + +(b) Add `state(timeline:errorMessage:now:)` inside `enum ActivityHistogram`, after `accessibilitySummary`, and append the controller at the end of the file: + +```swift + /// Decide what the submenu shows. A timeline that has loaded wins over a + /// recorded failure: showing real (if slightly stale) data beats showing an + /// error row. + static func state(timeline: [UsageBucket]?, errorMessage: String?, now: Date) -> HistogramState { + if let timeline { + return .loaded(bars(from: timeline, now: now)) + } + if let errorMessage, !errorMessage.isEmpty { + return .failed(errorMessage) + } + return .loading + } +``` + +```swift +// MARK: - Submenu + +/// Owns the tray's "Activity (24h)" item and rebuilds its single row when the +/// submenu opens. +/// +/// Building on open — rather than on every `rebuildMenu()` — keeps the chart +/// off the main menu's hot path, and means an `NSHostingView` is only ever +/// created when the user actually looks at it. The controller reads `AppState` +/// and nothing else: opening the submenu performs no network request. +/// +/// `NSMenu.delegate` is a weak reference, so whoever inserts `menuItem` into +/// the tray menu must also hold on to this object. +final class ActivityHistogramSubmenu: NSObject, NSMenuDelegate { + + /// The item to insert into the tray menu. + let menuItem: NSMenuItem + + private let appState: AppState + private let now: () -> Date + private let chartItemFactory: ([HistogramBar]) -> NSMenuItem + + /// - Parameters: + /// - now: injected clock, so the 24-hour axis is deterministic in tests. + /// - chartItemFactory: injected so tests can assert on submenu structure + /// without instantiating an `NSHostingView`. + init(appState: AppState, + now: @escaping () -> Date = Date.init, + chartItemFactory: @escaping ([HistogramBar]) -> NSMenuItem = ActivityHistogram.chartMenuItem) { + self.appState = appState + self.now = now + self.chartItemFactory = chartItemFactory + + let item = NSMenuItem(title: "Activity (24h)", action: nil, keyEquivalent: "") + let submenu = NSMenu(title: "Activity (24h)") + // Nothing in here is actionable, and AppKit's automatic enabling runs + // its own validation at display time. Turning it off makes the rows' + // disabled state ours — and makes what the tests assert the same thing + // the user sees. + submenu.autoenablesItems = false + item.submenu = submenu + self.menuItem = item + + super.init() + submenu.delegate = self + } + + // MARK: NSMenuDelegate + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + menu.addItem(currentItem()) + } + + // MARK: Rows + + /// The single row the submenu shows for the current `AppState`. + func currentItem() -> NSMenuItem { + switch ActivityHistogram.state( + timeline: appState.usageTimeline, + errorMessage: appState.usageError, + now: now() + ) { + case .loading: + return Self.mutedItem("Loading…") + case .failed(let message): + let item = Self.mutedItem("Usage unavailable") + item.toolTip = message + return item + case .loaded(let bars): + return chartItemFactory(bars) + } + } + + /// A disabled, secondary-coloured text row. Setting `attributedTitle` + /// leaves `title` intact, so the plain string stays available to tests and + /// to accessibility. + static func mutedItem(_ title: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + item.attributedTitle = NSAttributedString(string: title, attributes: [ + .font: NSFont.menuFont(ofSize: 0), + .foregroundColor: NSColor.secondaryLabelColor + ]) + return item + } +} +``` + +- [ ] **Step 22: Run the submenu tests and watch them pass** + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test --filter ActivityHistogramSubmenuTests 2>&1 | tail -12 +``` + +Expect: + +``` +Test Case '-[MCPProxyTests.ActivityHistogramSubmenuTests testChartRowWinsOverAStaleFailure]' passed (0.002 seconds). +Test Case '-[MCPProxyTests.ActivityHistogramSubmenuTests testErrorRowWhenTheFetchFailedBeforeAnyTimelineArrived]' passed (0.001 seconds). +Test Case '-[MCPProxyTests.ActivityHistogramSubmenuTests testLoadingRowWhileTheTimelineIsNil]' passed (0.001 seconds). +Test Case '-[MCPProxyTests.ActivityHistogramSubmenuTests testReopeningReplacesTheRowAndAnIdleTimelineStillCharts]' passed (0.001 seconds). +Test Case '-[MCPProxyTests.ActivityHistogramSubmenuTests testStateResolution]' passed (0.000 seconds). +Test Case '-[MCPProxyTests.ActivityHistogramSubmenuTests testSubmenuIsEmptyUntilItOpens]' passed (0.001 seconds). +Executed 6 tests, with 0 failures (0 unexpected) in 0.006 (0.007) seconds +``` + +- [ ] **Step 23: Run the whole native suite for regressions** + +This is exactly what the `swift-test` job in `.github/workflows/native-tests.yml` runs (`working-directory: native/macos/MCPProxy`, `run: swift test`, no `--skip`). + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy && swift test 2>&1 | tail -6 +``` + +Expect a green run — `0 failures`, and a test count 15 higher than before this task (4 + 2 + 3 + 6): + +``` +Test Suite 'All tests' passed at .... + Executed NNN tests, with 0 failures (0 unexpected) in X.XXX (X.XXX) seconds +Testing Library Version: 1902 +Test run with 0 tests in 0 suites passed after 0.001 seconds. +``` + +(The trailing "0 tests in 0 suites" line is the swift-testing runner; this package has no swift-testing tests, only XCTest. That is normal.) + +- [ ] **Step 24: Commit the submenu controller** + +```bash +cd /Users/user/repos/mcpproxy-go && git add native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift && git commit -m "feat(tray): build the Activity (24h) submenu when it opens + +An NSMenuDelegate on the submenu builds its single row on menuNeedsUpdate: +the hosted chart when a timeline is loaded, a muted Loading… row before the +first refresh, a muted Usage unavailable row (message in the tooltip) when +the fetch failed. Reads AppState only — no request on open." +``` + +--- + +## Verification + +Run this end-to-end once every task has landed. Nothing here is optional: each command corresponds to a CI job or to a design constraint that unit tests cannot check. + +### 1. Go — build, test, spec, lint + +```bash +cd /Users/user/repos/mcpproxy-go + +# Both editions compile +go build ./... && go build -tags server ./... && echo BUILD_OK + +# Unit + race for the touched packages, then the whole tree +go test ./internal/storage/ ./internal/httpapi/ ./internal/runtime/ ./internal/server/ -count=1 +go test -race ./internal/... + +# OpenAPI artifacts must be byte-identical to a fresh generation (CI job: verify-oas) +make swagger-verify 2>&1 | tail -2 # expect "✅ OpenAPI artifacts are up to date." + +# The strict CI linter (golangci-lint v2, .github/.golangci.yml) +/opt/homebrew/bin/golangci-lint run --config .github/.golangci.yml ./... + +# API end-to-end +./scripts/test-api-e2e.sh +``` + +### 2. Go — the new parameter, against a live core + +Use an isolated instance so the tray's core on `:8080` keeps its DB lock: + +```bash +cd /Users/user/repos/mcpproxy-go +go build -o /tmp/mcpproxy-glance ./cmd/mcpproxy +/tmp/mcpproxy-glance serve --listen 127.0.0.1:18080 --data-dir /tmp/mcpproxy-glance-data & +KEY=$(jq -r .api_key /tmp/mcpproxy-glance-data/mcp_config.json) + +curl -s -H "X-API-Key: $KEY" 'http://127.0.0.1:18080/api/v1/sessions?status=active&limit=25' | jq '.success, .data.total' +curl -s -o /dev/null -w '%{http_code}\n' -H "X-API-Key: $KEY" 'http://127.0.0.1:18080/api/v1/sessions?status=bogus' # expect 400 +curl -s -H "X-API-Key: $KEY" 'http://127.0.0.1:18080/api/v1/activity/usage?window=24h&top=1' | jq '.data.timeline | length' + +pkill -f 'mcpproxy-glance serve' +``` + +### 3. Swift — build and full native suite + +```bash +cd /Users/user/repos/mcpproxy-go/native/macos/MCPProxy +swift build +swift test # exactly what .github/workflows/native-tests.yml runs; no --skip +``` + +Pass criterion is `0 failures (0 unexpected)`. The trailing `✔ Test run with 0 tests in 0 suites passed` line is the swift-testing runner and is expected — this package is XCTest-only. + +### 4. macOS tray — visual and accessibility check + +Follow `docs/development/macos-tray.md` (build → replace the app bundle → verify). Screen Recording permission is required for the screenshot tools. + +```bash +cd /Users/user/repos/mcpproxy-go +./scripts/build-macos-tray.sh # then replace /Applications/MCPProxy.app per the doc +open -a MCPProxy +``` + +Then, with the `mcpproxy-ui-test` MCP tools: + +1. `list_running_apps` — confirm `MCPProxy` is up. +2. `read_status_bar` / `list_menu_items` — the glance block appears between the status header and "Needs Attention", in the order: summary · Recent · rows · Open Activity… · Clients · rows · Activity (24h). +3. `screenshot_status_bar_menu` — visually confirm status icons (shape, not colour alone), right-aligned relative ages, middle-truncated long tool names. +4. `click_menu_item` on "Activity (24h)" then `screenshot_status_bar_menu` — the histogram renders; bars are stacked with errors distinct. +5. `send_keypress` (arrow keys) — every text row is keyboard-navigable. +6. `check_accessibility` — activity rows, client rows and the chart all carry labels; the chart's label reads "Activity over the last 24 hours: N calls, M errors. Busiest hour HH:MM with K calls." +7. Drive some traffic through the proxy from an agent while the menu is **open** — rows update in place, the menu does not grow, shrink, or collapse the open submenu. +8. `click_menu_item` on an activity row — the browser opens `/ui/activity?session=&apikey=…` and the Activity view is filtered to that session. +9. Stop the core from the menu — the whole glance block disappears; restart it and confirm the block returns empty/loading rather than showing the previous core's numbers. + +### 5. Success criteria checklist (from the design spec's Testing section) + +- [ ] **SSE naming regression** — `activity.tool_call.completed` reaches the activity handler (fails on `main` today). +- [ ] **No amplification** — handling one completed event issues zero REST requests; `activityVersion` is not bumped on the SSE tool-call path. +- [ ] **Session decoding regression** — `last_activity` decodes into `APIClient.MCPSession` (nil on `main` today). +- [ ] **Selection** — upstream `tool_call` always included; the three discovery/execution built-ins included; a failed `call_tool_write` record is included; a failed `upstream_servers` record is still excluded (rule 1 beats rule 3); five qualifying records selected from a 50-record page full of noise. +- [ ] **Header bucket** — a sparse timeline whose newest bucket is three hours old shows zero calls this hour, not that bucket's count. +- [ ] **Dashboard non-regression** — `recentActivity` still carries non-tool-call types and `recentSessions` still carries closed sessions after the glance feeds are added. +- [ ] **Deduplication** — a failed upstream call that emitted both a `tool_call` and a `call_tool_read` record under one request id renders exactly one row, the `tool_call` one; a pre-dispatch wrapper failure with no paired record still renders. +- [ ] **Open-menu stability** — a burst of SSE events updates rows in place and performs no structural rebuild; the deferred rebuild runs exactly once on close. +- [ ] **Row identity after in-place update** — `representedObject`, image, tooltip and accessibility label all describe the record the title now shows. +- [ ] **SSE adapter** — an `activity.internal_tool_call.completed` payload (`internal_tool_name`, optional `target_server`, Unix-seconds envelope) becomes an entry that passes selection and renders a correct relative time. +- [ ] **Reconnect hygiene** — after a disconnect, glance state is cleared, so a menu built between `.connected` and the first successful refresh shows empty/loading states. +- [ ] **Optimistic rows** — a row built from an SSE payload carries the failure text from `error_message`. +- [ ] **Chart data** — `calls - errors` and `errors` segments never double-count; missing hours synthesised as zero across a stable 24-hour axis. +- [ ] **Formatting** — relative time, `server:tool` label composition, middle truncation. +- [ ] **Visibility** — block hidden when the core is stopped or disconnected; empty-state rows ("No tool calls yet", "No connected clients") render as single muted rows. +- [ ] **Clients honesty** — an old-but-active session is returned by `?status=active` even when newer sessions would fill the page (storage-level test). +- [ ] **Invariant, spec 048** — opening the main menu, and opening the histogram submenu, issues no network requests; pinned by `CountingGlanceDataSource` and by `GlanceSection`/`ActivityHistogramSubmenu` reading `AppState` only. +- [ ] **Dead code removed** — `Menu/TrayMenu.swift` is deleted and nothing references `TrayMenu`. +- [ ] **VoiceOver pass** — rows and the chart's accessibility label verified live with `check_accessibility`. diff --git a/docs/superpowers/specs/2026-07-29-tray-glance-design.md b/docs/superpowers/specs/2026-07-29-tray-glance-design.md new file mode 100644 index 000000000..db59709ab --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-tray-glance-design.md @@ -0,0 +1,185 @@ +# Tray Glance — compact activity, clients, and 24h histogram in the macOS menu + +**Status:** Design (2026-07-29, revised after cross-model review) +**Epic:** `ux-audit` → `ux-audit-macos-sweep` (roadmap.yaml, P0); folds in `action-log-transparency` → `action-log-tray-menu` +**Reference:** [CodexBar](https://github.com/steipete/CodexBar) — rich SwiftUI content hosted inside a plain `NSMenu` + +## Problem + +The macOS tray menu shows what MCPProxy *is* (servers, profiles, version, update state) but nothing about what it is *doing*. To see whether an agent is calling tools, which client is connected, or whether calls are failing, the user must open the Web UI. The activity log is a headline feature of the product and is invisible from the surface people actually keep open. + +## Goal + +A compact glance section at the top of the tray menu answering three questions, without turning the menu into a dashboard: + +1. What just happened? — the five most recent tool calls +2. Who is connected? — active MCP clients +3. How busy has it been? — calls per hour over the last 24h (in a submenu) + +Constraints, in priority order: never irritate (the menu stays a menu — fast, keyboard-navigable, VoiceOver-friendly); never lie (no stale data presented as live, no metric labelled as something it is not); never regress menu-open cost (spec 048's zero-network menu open is an invariant). + +## Decisions + +| Question | Decision | Rationale | +|---|---|---| +| Presentation | Plain `NSMenuItem`s for all text rows; a hosted SwiftUI view **only** for the histogram, inside its submenu | Apple documents that custom menu-item views receive mouse events but **not** keyboard events — rows built as SwiftUI buttons would not be keyboard-selectable and would need hand-rolled accessibility. The rows are text plus an icon; native items give keyboard navigation, highlighting, and VoiceOver for free. Only the chart genuinely needs custom drawing. | +| What is visible on open | Activity + clients; histogram in a submenu | Keeps the menu short. The histogram is a visualisation, not a glance signal. | +| Histogram metric | Calls per hour, errors as a distinct stacked segment | No hourly token series exists: `UsageTimeBucket` carries `calls`, `errors`, `total_resp_bytes`, and the endpoint reports `token_source: "bytes"` — a size proxy. Real tokenisation (`cl100k_base`) accumulates only per session (`MCPSession.total_tokens`), never per activity record. Plotting calls is exact and needs no storage change. A real token histogram is a separate roadmap item. | +| Liveness | Consume `activity.*` SSE payloads directly; keep the 30s poll as reconciliation | `CoreProcessManager.swift:683` matches `case "activity"`, which the core never emits, so activity is 30s-polled today. Merely fixing the name is not enough: that branch calls `refreshActivity()`, so prefix-matching would fire a REST GET per event — network amplification, not push. The `activity.tool_call.completed` payload already carries `server_name`, `tool_name`, `session_id`, `request_id`, `status`, `error_message`, `duration_ms` (`internal/runtime/event_bus.go:444`) — everything a row renders. Note the field is `error_message`, matching `ActivityEntry`; a row built from a payload key named `error` would silently lose the failure detail until the next poll. | +| Integration | Extract a `Menu/Glance/` component; no view caching, but suppress structural rebuilds while the menu is open | With plain items, `rebuildMenu()`'s `removeAllItems()` is as cheap for glance rows as for today's items — the caching scheme an earlier draft proposed is unnecessary and would not have prevented churn anyway. But live SSE rows make the existing debounced `objectWillChange → rebuildMenu()` sink (`MCPProxyApp.swift:114`) fire during active traffic, i.e. potentially while the user is reading the menu. See "Rebuilds while the menu is open" below. | + +## Architecture + +New component under `native/macos/MCPProxy/MCPProxy/Menu/Glance/`: + +- **`GlanceSection.swift`** — the only thing `MCPProxyApp` talks to: `func items(for state: AppState) -> [NSMenuItem]`, returning plain, actionable `NSMenuItem`s plus one submenu item. +- **`GlanceSelection.swift`** — the display rules (which records qualify, ordering, capping). Pure functions over `[ActivityEntry]` / `[MCPSession]`, which is what makes them unit-testable without AppKit. +- **`GlanceFormatting.swift`** — status icon, row label composition, relative time, middle truncation. Salvaged from the dead `Menu/TrayMenu.swift` (511 lines, referenced nowhere, already contains `activityIcon(for:)`, `activitySummaryText(for:)`, `relativeTime(_:)`); that file is then deleted. +- **`ActivityHistogramView.swift`** — SwiftUI Charts bar chart hosted in the submenu's single custom item, built when the submenu opens. + +Changes to existing files: + +- `MCPProxyApp.swift` — `rebuildMenu()` inserts `glance.items(for: appState)` after the status header, before "Needs Attention", plus the histogram submenu item. Roughly five lines; no new layout code in an already 1120-line file. +- `CoreProcessManager.swift:683` — replace the dead `case "activity"` with handlers for `activity.tool_call.completed` and `activity.internal_tool_call.completed` that build a row from the event payload and prepend it, with no REST call. `activity.tool_call.started` is deliberately ignored (the core does not persist started events either — `activity_service.go:418`). +- `APIClient.swift:341,353` — decode `last_activity` (the field the API emits) instead of `last_active`. +- `APIClient.swift` — add a usage-aggregate call and its response model, and a *separate* glance-activity call carrying the type filter. +- `AppState.swift` — add `@Published var glanceActivity: [ActivityEntry]`, `glanceSessions: [MCPSession]`, `usageTimeline: [UsageBucket]?` and `callsThisHour: Int?` alongside the existing `recentActivity` / `recentSessions`. The usage fields are optional so that "not loaded yet" is distinguishable from "loaded, and the proxy was idle" — a valid usage response can legitimately carry an empty timeline. + +Neither `AppState.recentActivity` nor `recentSessions` is narrowed. The native Dashboard deliberately renders the full activity log from the former — security scans, quarantine changes, OAuth events — precisely so a quiet proxy still shows something (`Views/DashboardView.swift:572`), and the latter backs session-name lookup in `ActivityView` and the Dashboard's session list. Filtering either for the tray's benefit would silently gut those views, so the glance section gets its own feeds. + +## Layout + +``` +MCPProxy v0.52.0 ● +12 calls this hour · 2 clients +───────────────────────────────────── +Recent + ✓ github:create_issue 12s + ✓ obsidian:search_notes 1m + ✕ jira:get_issue · auth failed 3m + ✓ retrieve_tools "docker" 5m + ✓ code_execution 8m + Open Activity… +───────────────────────────────────── +Clients + ● Claude Code 8 calls · now + ● Cursor 4 calls · 2m +───────────────────────────────────── +Activity (24h) ▸ +───────────────────────────────────── +Servers (12) ▸ +Profile: default ▸ +… +``` + +Row anatomy: status icon (shape *and* colour, never colour alone), `server:tool` for upstream calls or the built-in's name for discovery/execution calls, relative age right-aligned. A failed row appends the first clause of `errorMessage`, truncated to one line, with the full message in the tooltip. UI strings are English, matching the rest of the menu; the mockup follows the repo's own `docs/superpowers/specs/macos-design-guide.md` (semantic colours, SF Symbols). + +## Data flow + +**Menu open performs zero network requests.** Everything renders from `AppState`. Three background feeds keep it current. + +### Activity + +Live rows come from SSE. On `activity.tool_call.completed` / `activity.internal_tool_call.completed`, the tray builds an entry from the event payload and prepends it — no GET per event. + +**The payload is not an `ActivityEntry` and must be adapted explicitly.** The wire format is an envelope, `{payload, timestamp}`, whose timestamp is Unix seconds (`internal/httpapi/server.go:3205`), while `ActivityEntry` expects an id, a type, and an ISO-8601 string. More subtly, the two event kinds use different keys: upstream calls send `server_name` / `tool_name` (`event_bus.go:444`), whereas internal calls send `internal_tool_name`, plus `target_server` / `target_tool` only when non-empty (`event_bus.go:552`). A `GlanceEvent` adapter therefore maps: event name → `type`; `internal_tool_name` → `toolName` for internal events; `target_server` → `serverName` when present; envelope timestamp → `Date`; and synthesises a provisional id from `request_id` **plus the event type**, which the reconciling poll then replaces with the storage-assigned ULID. The composite matters: `ActivityEntry` derives identity and equality from `id` alone (`API/Models.swift:536`), and the paired upstream/wrapper events deliberately share a request id, so a bare `request_id` would make two distinct records collide before rule 4 ever gets to choose between them. Feeding the raw payload into the selection rules would silently drop every internal row, since its `toolName` would be empty. A dedicated poll every 30 seconds reconciles that optimistic list with the server's canonical records (storage-assigned ids and the asynchronously-computed sensitive-data flags): `GET /api/v1/activity?type=tool_call,internal_tool_call&limit=50`. This is a fourth call on the existing refresh cycle rather than a reuse of the Dashboard's feed, for the reason given above. + +**Which records qualify** — evaluated in this order, in `GlanceSelection`, identically for polled records and SSE payloads: + +1. **Exclude** any record whose tool is a management built-in (`upstream_servers`, `quarantine_security`), regardless of status. These are proxy administration — what "exclude internal mcpproxy events" asks for. This rule wins over everything below. +2. **Include** every `type == "tool_call"` record. These are the real upstream calls. +3. **Include** a remaining `type == "internal_tool_call"` record when its tool is `retrieve_tools`, `code_execution`, or `describe_tool`, **or** when its status is not `success`. +4. **Collapse** records sharing a `request_id`, keeping the `tool_call` one. A failed upstream call emits *both* `activity.tool_call.completed` and `activity.internal_tool_call.completed` under one request id, and both are persisted (`internal/server/mcp.go:2142` and `:2146`) — so rules 2 and 3 would otherwise render one failure as two rows. The surviving `tool_call` record is the better row because it names the real `server:tool`; the wrapper only names `call_tool_read`. + +Rule 3's second clause is what makes rule 4 safe to apply. The backend keeps *failed* `call_tool_*` wrappers while suppressing successful ones, because a wrapper that failed **before dispatch** has no upstream record at all (`internal/storage/activity_models.go:255`). Rule 3 admits those, rule 4 discards the redundant copies, and the net effect is one row per failed call whether it died before or after reaching the server. A plain built-in allowlist — the earlier draft — would have hidden the pre-dispatch failures entirely, which are exactly the ones this section exists to surface. Rule 1 comes first because management built-ins fail like any other internal call, and their failures are still administration noise. + +The poll fetches 50 records and the section shows the first five that qualify. The page is deliberately oversized because rule 1 runs on the client: the server-side `type` filter admits management built-ins (they *are* `internal_tool_call` records), and storage applies its filter before truncating to the limit (`internal/storage/activity.go:147`), so a burst of `upstream_servers` calls would otherwise fill a small page and push real tool calls out of view. Fifty is not a guarantee — an agent that makes fifty consecutive management calls will legitimately see the empty state — but it moves the failure mode from "routine" to "pathological". If fewer than five qualify, the section shows what it has. + +This selection is presentation policy, not tray-held state, so it stays within the "tray holds no state" rule (CLAUDE.md). + +### Header summary and histogram + +Both come from one call on the existing 30-second background loop: `GET /api/v1/activity/usage?window=24h&top=1`. It is served from an in-memory snapshot behind a short TTL cache and never scans the log; `top=1` trims the per-tool payload the tray does not use. + +The header reads **"calls this hour"**, not "in the last hour", and that wording is load-bearing. Buckets are aligned to the UTC hour (`rec.Timestamp.UTC().Truncate(time.Hour)`, `internal/runtime/usage_aggregate.go:229`), so they are neither a rolling 60-minute window nor guaranteed to be dense — the response omits hours with no activity. The header therefore selects the bucket whose start equals the current UTC hour and shows zero when that bucket is absent. Taking "the most recent bucket" instead would display a count from hours ago as if it were current. + +An earlier draft used `GET /api/v1/activity/summary?period=1h` here. That was wrong on both counts: the handler sets `Limit = 0` and loads every matching record (`internal/httpapi/activity.go:553`) — a log scan, not a pre-aggregate — and it counts *all* activity types, so labelling its total "calls" would be false. + +Because the timeline is already in `AppState`, the histogram submenu renders instantly with no fetch of its own. + +Chart semantics: a bucket's `calls` **includes** its `errors`, so the stacked bars plot `calls - errors` and `errors` as two segments — stacking the raw fields would double-count failures. The endpoint returns only buckets that exist, so missing hours are synthesised as zero to keep a stable 24-hour axis. Timeline buckets are global by contract (not filtered by server, tool, or status), so the label stays "Activity (24h)" with no implication of filtering. The chart item carries an accessibility label summarising the series (total calls, peak hour, error count) since a bar chart is opaque to VoiceOver. + +### Clients + +This part needs a small backend change, and the reason is worth stating plainly. Storage walks sessions newest-first **by start time** and truncates to `limit` (`internal/storage/manager.go:1355`); only that already-truncated subset is then sorted by last activity (`internal/runtime/runtime.go:1340`), and the handler parses `offset` without passing it down (`internal/httpapi/server.go:4844`), so pagination cannot recover what the truncation dropped. A session that started long ago but is calling tools right now therefore falls outside *any* client-side-filtered page once enough newer sessions exist. An earlier draft answered this by fetching the 100-record maximum and calling it "acceptable for correctness" — but that is still a page, so the section could show "No connected clients" while a client is actively working. That is the kind of quiet lie this design's second constraint forbids. + +So: add a `status` query parameter to `GET /api/v1/sessions`, applied during the storage cursor walk (before truncation), and have the glance section request `?status=active&limit=25` into its **own** `@Published glanceSessions` field. This is roughly twenty lines in the handler and storage filter, plus the usual `make swagger` regeneration and a handler test. + +The separate field is not incidental. `AppState.recentSessions` is shared: `ActivityView` resolves session ids to client names through it (`Views/ActivityView.swift:713`) and `DashboardView` falls back to it for "Recent Sessions" (`Views/DashboardView.swift:467`). Repointing that single feed at active-only sessions would silently narrow both — the same mistake this design already avoided once with `recentActivity`, and it is worth naming as a pattern: **any shared `AppState` feed the glance section wants differently gets its own field, never a narrowed shared one.** + +Documented caveat that the parameter does not fix: per spec 082, handshake-only sessions are not persisted, so an idle background agent does not appear until its first call. This is stated in the UI copy's favour — "clients" means clients that have done something. + +### Clicks + +An activity row and a client row both open the Web UI activity log filtered by that record's **session** — `/ui/activity?session=`, the query parameter the Activity view already reads on mount (`frontend/src/views/Activity.vue:1333`). Filtering by `request_id` was considered and rejected: the REST API supports it but the Web UI does not read such a route parameter, so it would have required a frontend change that this design excludes. + +`GlanceSection` does not build these URLs itself. It cannot: it is handed only `AppState`, whose `webUIBaseURL` is scheme/host/port by design, and a first-time browser session needs the API key appended — the existing `openWebUI()` action fetches `currentAPIKey` from the core manager for exactly this reason (`MCPProxyApp.swift:985`). Glance rows therefore carry a target/action pair pointing back at the app delegate, which opens the authenticated URL through the same path as today's menu items. Reusing that path also keeps the key handling in one place rather than duplicating it in a new component. + +### Rebuilds while the menu is open + +Live rows create a problem the current menu does not have. Every `AppState` change feeds a debounced sink that calls `rebuildMenu()`, which calls `removeAllItems()` (`MCPProxyApp.swift:114`, `:523`) — and the menu is not dismissed when that happens. Today the only sources of change are server state and a 30-second poll, so this is rare. With SSE rows, a busy agent changes state every few hundred milliseconds, potentially while the user is reading the menu or navigating it with the keyboard, and re-creating the item that owns the histogram submenu would disturb an open submenu. + +So `rebuildMenu()` gains a tracking guard: while the menu is open it does **not** restructure. Glance rows update in place — mutating an existing `NSMenuItem` is exactly how live menus are meant to work — and any structural change (a new row appearing, the section becoming empty) is deferred, flagged dirty, and applied on `menuDidClose`. A menu that grows or shrinks under the cursor is precisely the "irritating" behaviour this design set out to avoid, so this guard is a requirement, not an optimisation. + +"In place" means the row's **entire identity**, not just its text. Once five rows exist, each new event shifts which record every row represents, so an update must rewrite the title, the status image, the tooltip, the accessibility label, and the `representedObject` that carries the session id — the same mechanism today's server rows already use to pass identity to their action (`MCPProxyApp.swift:606`). Refreshing only the title would leave a row reading like the new record while its click still opened the previous record's session: a wrong destination is worse than a stale one, because the user cannot tell it happened. + +## States + +| Situation | Behaviour | +|---|---| +| Core stopped or disconnected | The whole glance block is hidden **and its state is cleared** — `glanceActivity` and `glanceSessions` emptied, `usageTimeline` and `callsThisHour` set back to `nil`. Clearing is not redundant with hiding: the connection path flips to `.connected` before the first refresh completes (`CoreProcessManager.swift:330`), so without a reset the menu would briefly present the previous core's numbers as live. Optional fields alone do not prevent this; they only make the cleared state expressible. | +| No activity yet | One muted row, "No tool calls yet" — not an empty section with a header. | +| No active clients | One muted row, "No connected clients". | +| Usage data not loaded yet (`callsThisHour == nil`) | The header omits the call count (clients only); the histogram submenu shows a muted "Loading…" row. | +| Usage loaded but the proxy was idle | The header shows "0 calls this hour" and the chart shows a flat 24-hour axis — deliberately distinct from the loading state above, which is why these fields are optional rather than defaulting to zero. | +| Long tool names | Middle-truncated, keeping the server prefix and the tail of the tool name; full text in the tooltip. | + +## Testing + +Swift unit tests in the existing `swift-test` CI job. `GlanceSelection` and `GlanceFormatting` are pure functions, so the bulk of the logic tests need no AppKit: + +- **Regression, SSE naming**: `activity.tool_call.completed` reaches the activity handler (fails today). +- **Regression, no amplification**: handling one completed event issues zero REST requests. +- **Regression, session decoding**: `last_activity` decodes into the model (nil today). +- **Selection**: upstream `tool_call` always included; the three discovery/execution built-ins included; **a failed `call_tool_write` record is included** (the finding above, pinned by a test); **a failed `upstream_servers` record is still excluded** (rule 1 beats rule 3); five qualifying records selected from a 50-record page containing noise (the page size the production feed requests). +- **Header bucket**: with a sparse timeline whose newest bucket is three hours old, the header shows zero — not that bucket's count. +- **Dashboard non-regression**: `AppState.recentActivity` still carries non-tool-call types, and `recentSessions` still carries closed sessions, after the glance feeds are added. +- **Deduplication**: a failed upstream call that emitted both a `tool_call` and a `call_tool_read` record under one request id renders exactly one row, and it is the `tool_call` one; a pre-dispatch wrapper failure with no paired record still renders. +- **Open-menu stability**: with the menu tracking, a burst of SSE events updates rows in place and performs no structural rebuild; the deferred rebuild runs once on close. +- **Row identity after in-place update**: after an update, a row's `representedObject`, image, tooltip, and accessibility label all describe the record its title now shows — pinned by asserting the click payload changes with the title. +- **SSE adapter**: an `activity.internal_tool_call.completed` payload (`internal_tool_name`, optional `target_server`, Unix-seconds envelope) becomes an entry that passes selection and renders a correct relative time. +- **Reconnect hygiene**: after a disconnect, glance state is cleared, so a menu built between `.connected` and the first successful refresh shows the empty/loading states rather than the previous core's data. +- **Optimistic rows**: a row built from an SSE payload carries the failure text from `error_message` (a payload read keyed on `error` would render a failed call as if it had no detail). +- **Chart data**: `calls - errors` and `errors` segments never double-count; missing hours synthesised as zero across a 24-hour axis. +- Formatting: relative time, label composition, middle truncation. +- Visibility: block hidden when the core is stopped; empty-state rows. +- **Invariant, spec 048**: opening the main menu issues no requests. This requires the glance component to depend on a narrow data-source protocol rather than the concrete `APIClient` actor, so a counting stub can be injected — a small extraction, included in this work. + +Visual verification by screenshotting the menu with `mcpproxy-ui-test` per `docs/development/macos-tray.md` (requires Screen Recording permission), including a VoiceOver pass over the rows and the chart's accessibility label. + +## Scope note + +This is not "rendering plus two one-line fixes". The honest inventory: a new menu component (four files), a rewritten SSE activity path that consumes payloads through an explicit adapter instead of refetching, a tracking guard in `rebuildMenu()`, two field-level bug fixes, three new API client methods plus response models (usage aggregate, filtered glance activity, active-session feed), four new `AppState` fields, a data-source protocol extraction for testability, deletion of a dead 511-line file, one small Go change (a `status` filter on `/api/v1/sessions` plus `make swagger`), and the test suite above. + +## Non-goals + +Real token histogram (separate roadmap item: accumulate `TokenMetrics` into hourly buckets in `internal/runtime/usage_aggregate.go`, extend the contract, regenerate OAS). Sensitive-data badges on activity rows. Go/systray parity. Any Web UI change. Rewriting the menu rebuild model into a diffing implementation — it fixes a problem nobody reported, at the cost of risk in the one UI every user sees. + +## Follow-ups + +- Real token histogram (see Non-goals). +- `offset` is parsed but never applied in the sessions handler (`internal/httpapi/server.go:4844`) — a latent paging bug this design works around rather than fixes. + +## Follow-ups this unblocks + +Completing `ux-audit-macos-sweep` unblocks the `action-log-transparency` epic and the remaining `analytics-default-landing` task, both of which currently sit behind `ux-audit` in the dependency graph. diff --git a/internal/httpapi/activity.go b/internal/httpapi/activity.go index fd6c86bf4..09847a94a 100644 --- a/internal/httpapi/activity.go +++ b/internal/httpapi/activity.go @@ -144,6 +144,7 @@ func parseActivityFilters(r *http.Request) storage.ActivityFilter { // @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)" // @Param offset query int false "Pagination offset (default 0)" +// @Param exclude_payloads query bool false "Omit arguments, response and metadata (default: false). For clients that render summary fields only; has_sensitive_data is still derived before metadata is dropped." // @Success 200 {object} contracts.APIResponse{data=contracts.ActivityListResponse} // @Failure 400 {object} contracts.APIResponse // @Failure 401 {object} contracts.APIResponse @@ -161,10 +162,26 @@ func (s *Server) handleListActivity(w http.ResponseWriter, r *http.Request) { return } - // Convert storage records to contract records + // Convert storage records to contract records. + // + // `exclude_payloads` is a projection, not a filter: it changes what is + // serialised, never which records match, so it is applied here rather than + // in the storage filter. Arguments, Response and Metadata are unbounded in + // practice (only Response is truncated, at 64KB), and a client that renders + // summary fields alone pays for them on every poll — measured against a real + // log, the newest 100 tool-call records are ~848KB whole and ~30KB projected. + // HasSensitiveData is derived by storageToContractActivity from Metadata + // BEFORE it is dropped, so the flag survives its source. + excludePayloads := r.URL.Query().Get("exclude_payloads") == "true" contractActivities := make([]contracts.ActivityRecord, len(activities)) for i, a := range activities { contractActivities[i] = storageToContractActivity(a) + if excludePayloads { + contractActivities[i].Arguments = nil + contractActivities[i].Response = "" + contractActivities[i].ResponseTruncated = false + contractActivities[i].Metadata = nil + } } response := contracts.ActivityListResponse{ diff --git a/internal/httpapi/activity_handlers_test.go b/internal/httpapi/activity_handlers_test.go index c00c5ad5a..08b3d79a0 100644 --- a/internal/httpapi/activity_handlers_test.go +++ b/internal/httpapi/activity_handlers_test.go @@ -204,7 +204,7 @@ func TestActivityList_SensitiveDataFilter(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp struct { - Success bool `json:"success"` + Success bool `json:"success"` Data contracts.ActivityListResponse `json:"data"` } err := json.NewDecoder(w.Body).Decode(&resp) @@ -231,7 +231,7 @@ func TestActivityList_SensitiveDataFilter(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp struct { - Success bool `json:"success"` + Success bool `json:"success"` Data contracts.ActivityListResponse `json:"data"` } err := json.NewDecoder(w.Body).Decode(&resp) @@ -249,6 +249,92 @@ func TestActivityList_SensitiveDataFilter(t *testing.T) { }) } +// TestActivityList_ExcludePayloads covers the projection the tray glance asks +// for. The glance renders summary fields only, but a full record carries +// arguments, response and metadata — measured against a real activity log, the +// newest 100 matching records are ~848 KB whole and ~30 KB in summary form, and +// the tray refetches every 30 seconds. +func TestActivityList_ExcludePayloads(t *testing.T) { + logger := zap.NewNop().Sugar() + activities := []*storage.ActivityRecord{ + { + ID: "activity-with-payload", + Type: storage.ActivityTypeToolCall, + ServerName: "github", + ToolName: "create_issue", + Status: "error", + ErrorMessage: "auth failed", + DurationMs: 42, + RequestID: "req-1", + SessionID: "sess-1", + Timestamp: time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC), + Arguments: map[string]interface{}{"title": "a very large argument blob"}, + Response: "a very large response body", + Metadata: map[string]interface{}{ + "sensitive_data_detection": map[string]interface{}{ + "detected": true, + "detections": []interface{}{ + map[string]interface{}{"type": "github_token", "severity": "high"}, + }, + }, + }, + }, + } + mockCtrl := &mockActivityController{apiKey: "test-key", activities: activities} + srv := NewServer(mockCtrl, logger, nil) + + get := func(t *testing.T, path string) contracts.ActivityRecord { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("X-API-Key", "test-key") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var resp struct { + Success bool `json:"success"` + Data contracts.ActivityListResponse `json:"data"` + } + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + require.Len(t, resp.Data.Activities, 1) + return resp.Data.Activities[0] + } + + t.Run("exclude_payloads=true drops the three heavy fields", func(t *testing.T) { + got := get(t, "/api/v1/activity?exclude_payloads=true") + + assert.Nil(t, got.Arguments, "arguments must not be serialised") + assert.Empty(t, got.Response, "response must not be serialised") + assert.Nil(t, got.Metadata, "metadata must not be serialised") + }) + + t.Run("exclude_payloads=true keeps every field the glance renders", func(t *testing.T) { + got := get(t, "/api/v1/activity?exclude_payloads=true") + + assert.Equal(t, "activity-with-payload", got.ID) + assert.Equal(t, contracts.ActivityType("tool_call"), got.Type) + assert.Equal(t, "github", got.ServerName) + assert.Equal(t, "create_issue", got.ToolName) + assert.Equal(t, "error", got.Status) + assert.Equal(t, "auth failed", got.ErrorMessage) + assert.Equal(t, int64(42), got.DurationMs) + assert.Equal(t, "req-1", got.RequestID) + assert.Equal(t, "sess-1", got.SessionID) + assert.False(t, got.Timestamp.IsZero()) + // Derived from metadata BEFORE it is dropped: the tray's row icon and + // the poll's late correction both key off this flag. + assert.True(t, got.HasSensitiveData, "the flag survives its source being dropped") + }) + + t.Run("the default response is unchanged", func(t *testing.T) { + got := get(t, "/api/v1/activity") + + assert.NotNil(t, got.Arguments, "omitting the parameter must not change what other clients get") + assert.Equal(t, "a very large response body", got.Response) + assert.NotNil(t, got.Metadata) + }) +} + func TestActivityList_DetectionTypeFilter(t *testing.T) { logger := zap.NewNop().Sugar() activities := createTestActivityRecords() @@ -268,7 +354,7 @@ func TestActivityList_DetectionTypeFilter(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp struct { - Success bool `json:"success"` + Success bool `json:"success"` Data contracts.ActivityListResponse `json:"data"` } err := json.NewDecoder(w.Body).Decode(&resp) @@ -342,7 +428,7 @@ func TestActivityList_SeverityFilter(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp struct { - Success bool `json:"success"` + Success bool `json:"success"` Data contracts.ActivityListResponse `json:"data"` } err := json.NewDecoder(w.Body).Decode(&resp) @@ -618,7 +704,7 @@ func TestActivityResponse_SensitiveDataFields(t *testing.T) { "activity-2-credit-card": "high", "activity-3-multiple": "critical", // Has both critical and high, critical is max "activity-4-medium": "medium", - "activity-5-clean": "", // No sensitive data + "activity-5-clean": "", // No sensitive data } for _, activity := range resp.Data.Activities { @@ -680,7 +766,7 @@ func TestActivityDetail_SensitiveDataFields(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp struct { - Success bool `json:"success"` + Success bool `json:"success"` Data contracts.ActivityDetailResponse `json:"data"` } err := json.NewDecoder(w.Body).Decode(&resp) @@ -703,7 +789,7 @@ func TestActivityDetail_SensitiveDataFields(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) var resp struct { - Success bool `json:"success"` + Success bool `json:"success"` Data contracts.ActivityDetailResponse `json:"data"` } err := json.NewDecoder(w.Body).Decode(&resp) diff --git a/internal/httpapi/contracts_test.go b/internal/httpapi/contracts_test.go index 585caf24f..d55a50a6b 100644 --- a/internal/httpapi/contracts_test.go +++ b/internal/httpapi/contracts_test.go @@ -169,7 +169,7 @@ func (m *MockServerController) GetDockerRecoveryStatus() *storage.DockerRecovery } } func (m *MockServerController) IsDockerAvailable() bool { return true } -func (m *MockServerController) GetRecentSessions(_ int) ([]*contracts.MCPSession, int, error) { +func (m *MockServerController) GetRecentSessions(_ int, _ string) ([]*contracts.MCPSession, int, error) { return []*contracts.MCPSession{}, 0, nil } func (m *MockServerController) GetSessionByID(_ string) (*contracts.MCPSession, error) { diff --git a/internal/httpapi/security_test.go b/internal/httpapi/security_test.go index dac4e82f0..e2f0945d6 100644 --- a/internal/httpapi/security_test.go +++ b/internal/httpapi/security_test.go @@ -313,7 +313,7 @@ func (m *baseController) CallTool(ctx context.Context, toolName string, args map func (m *baseController) GetRuntime() *runtime.Runtime { return nil } func (m *baseController) GetSessions(limit, offset int) (interface{}, int, error) { return nil, 0, nil } func (m *baseController) GetSessionByID(id string) (*contracts.MCPSession, error) { return nil, nil } -func (m *baseController) GetRecentSessions(limit int) ([]*contracts.MCPSession, int, error) { +func (m *baseController) GetRecentSessions(limit int, status string) ([]*contracts.MCPSession, int, error) { return nil, 0, nil } func (m *baseController) GetToolCallsBySession(sessionID string, limit, offset int) ([]*contracts.ToolCallRecord, int, error) { diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 126685e28..b4ce96456 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -105,8 +105,9 @@ type ServerController interface { ReplayToolCall(id string, arguments map[string]interface{}) (*contracts.ToolCallRecord, error) GetToolCallsBySession(sessionID string, limit, offset int) ([]*contracts.ToolCallRecord, int, error) - // Session management - GetRecentSessions(limit int) ([]*contracts.MCPSession, int, error) + // Session management. status filters on session status ("active" / + // "closed"); an empty string means no filter. + GetRecentSessions(limit int, status string) ([]*contracts.MCPSession, int, error) GetSessionByID(sessionID string) (*contracts.MCPSession, error) // Configuration management @@ -4828,7 +4829,9 @@ func getBool(m map[string]interface{}, key string) bool { // @Produce json // @Param limit query int false "Maximum number of sessions to return (1-100, default 10)" // @Param offset query int false "Number of sessions to skip for pagination (default 0)" +// @Param status query string false "Filter by session status" Enums(active, closed) // @Success 200 {object} contracts.GetSessionsResponse "Sessions retrieved successfully" +// @Failure 400 {object} contracts.ErrorResponse "Invalid status filter" // @Failure 401 {object} contracts.ErrorResponse "Unauthorized - missing or invalid API key" // @Failure 405 {object} contracts.ErrorResponse "Method not allowed" // @Failure 500 {object} contracts.ErrorResponse "Failed to get sessions" @@ -4859,8 +4862,17 @@ func (s *Server) handleGetSessions(w http.ResponseWriter, r *http.Request) { } } + // Status filter. The domain is closed ("active" / "closed"), so an unknown + // value is a client bug — rejecting it is honest, where silently ignoring it + // would return unfiltered sessions that the caller believes are filtered. + status := r.URL.Query().Get("status") + if status != "" && status != "active" && status != "closed" { + s.writeError(w, r, http.StatusBadRequest, "Invalid status. Use 'active' or 'closed'") + return + } + // Get recent sessions from controller - sessions, total, err := s.controller.GetRecentSessions(limit) + sessions, total, err := s.controller.GetRecentSessions(limit, status) if err != nil { s.logger.Error("Failed to get sessions", "error", err) s.writeError(w, r, http.StatusInternalServerError, "Failed to get sessions") diff --git a/internal/httpapi/sessions_handlers_test.go b/internal/httpapi/sessions_handlers_test.go new file mode 100644 index 000000000..4420e794e --- /dev/null +++ b/internal/httpapi/sessions_handlers_test.go @@ -0,0 +1,142 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" +) + +// mockSessionController records the status argument the handler pushes down, so +// the tests can prove the filter reaches storage rather than being applied (or +// dropped) in the handler. +type mockSessionController struct { + baseController + apiKey string + sessions []*contracts.MCPSession + gotLimit int + gotStatus string + callCount int +} + +func (m *mockSessionController) GetCurrentConfig() any { + return &config.Config{APIKey: m.apiKey} +} + +func (m *mockSessionController) GetRecentSessions(limit int, status string) ([]*contracts.MCPSession, int, error) { + m.callCount++ + m.gotLimit = limit + m.gotStatus = status + + var out []*contracts.MCPSession + for _, s := range m.sessions { + if status == "" || s.Status == status { + out = append(out, s) + } + } + return out, len(out), nil +} + +func testSessions() []*contracts.MCPSession { + start := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) + return []*contracts.MCPSession{ + {ID: "sess-active", ClientName: "claude-code", Status: "active", StartTime: start, LastActivity: start.Add(time.Hour)}, + {ID: "sess-closed", ClientName: "cursor", Status: "closed", StartTime: start.Add(time.Minute), LastActivity: start.Add(2 * time.Minute)}, + } +} + +func decodeSessionsResponse(t *testing.T, w *httptest.ResponseRecorder) contracts.GetSessionsResponse { + t.Helper() + var resp struct { + Success bool `json:"success"` + Data contracts.GetSessionsResponse `json:"data"` + } + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + require.True(t, resp.Success) + return resp.Data +} + +func TestGetSessions_StatusFilter(t *testing.T) { + logger := zap.NewNop().Sugar() + + t.Run("status=active is pushed down and narrows the result", func(t *testing.T) { + ctrl := &mockSessionController{apiKey: "test-key", sessions: testSessions()} + srv := NewServer(ctrl, logger, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions?status=active&limit=25", nil) + req.Header.Set("X-API-Key", "test-key") + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "active", ctrl.gotStatus, "the status filter must reach the controller, not be applied in the handler") + assert.Equal(t, 25, ctrl.gotLimit) + + data := decodeSessionsResponse(t, w) + require.Len(t, data.Sessions, 1) + assert.Equal(t, "sess-active", data.Sessions[0].ID) + assert.Equal(t, 1, data.Total) + assert.Equal(t, 25, data.Limit) + }) + + t.Run("no status parameter means no filter", func(t *testing.T) { + ctrl := &mockSessionController{apiKey: "test-key", sessions: testSessions()} + srv := NewServer(ctrl, logger, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions", nil) + req.Header.Set("X-API-Key", "test-key") + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "", ctrl.gotStatus) + assert.Equal(t, 10, ctrl.gotLimit, "default limit for sessions is 10") + + data := decodeSessionsResponse(t, w) + assert.Len(t, data.Sessions, 2) + }) + + t.Run("status=closed selects closed sessions", func(t *testing.T) { + ctrl := &mockSessionController{apiKey: "test-key", sessions: testSessions()} + srv := NewServer(ctrl, logger, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions?status=closed", nil) + req.Header.Set("X-API-Key", "test-key") + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + data := decodeSessionsResponse(t, w) + require.Len(t, data.Sessions, 1) + assert.Equal(t, "sess-closed", data.Sessions[0].ID) + }) + + t.Run("unknown status is rejected and never reaches the controller", func(t *testing.T) { + ctrl := &mockSessionController{apiKey: "test-key", sessions: testSessions()} + srv := NewServer(ctrl, logger, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions?status=bogus", nil) + req.Header.Set("X-API-Key", "test-key") + w := httptest.NewRecorder() + + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, 0, ctrl.callCount, "a rejected filter must not fall through to an unfiltered query") + + var errResp contracts.ErrorResponse + require.NoError(t, json.NewDecoder(w.Body).Decode(&errResp)) + assert.Contains(t, errResp.Error, "Invalid status") + }) +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index f867c853f..82bbae445 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -1306,12 +1306,16 @@ func (r *Runtime) GetToolCallsBySession(sessionID string, limit, offset int) ([] return records, total, nil } -// GetRecentSessions returns recent MCP sessions -func (r *Runtime) GetRecentSessions(limit int) ([]*contracts.MCPSession, int, error) { +// GetRecentSessions returns recent MCP sessions. +// +// status filters on the session status ("active" / "closed"); an empty string +// means no filtering. The filter is pushed down into the storage cursor walk so +// it is applied before truncation to limit. +func (r *Runtime) GetRecentSessions(limit int, status string) ([]*contracts.MCPSession, int, error) { r.mu.RLock() defer r.mu.RUnlock() - storageRecords, total, err := r.storageManager.GetRecentSessions(limit) + storageRecords, total, err := r.storageManager.GetRecentSessions(limit, status) if err != nil { return nil, 0, fmt.Errorf("failed to get recent sessions: %w", err) } diff --git a/internal/server/server.go b/internal/server/server.go index a62840726..046c1300a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3072,9 +3072,10 @@ func (s *Server) GetToolCallsBySession(sessionID string, limit, offset int) ([]* return s.runtime.GetToolCallsBySession(sessionID, limit, offset) } -// GetRecentSessions retrieves recent MCP sessions -func (s *Server) GetRecentSessions(limit int) ([]*contracts.MCPSession, int, error) { - return s.runtime.GetRecentSessions(limit) +// GetRecentSessions retrieves recent MCP sessions, optionally filtered by +// status ("active" / "closed"; empty means no filter). +func (s *Server) GetRecentSessions(limit int, status string) ([]*contracts.MCPSession, int, error) { + return s.runtime.GetRecentSessions(limit, status) } // GetSessionByID retrieves a session by its ID diff --git a/internal/storage/manager.go b/internal/storage/manager.go index 2a197785b..dfeb9297d 100644 --- a/internal/storage/manager.go +++ b/internal/storage/manager.go @@ -1335,8 +1335,15 @@ func (m *Manager) CloseSession(sessionID string) error { }) } -// GetRecentSessions returns the most recent sessions -func (m *Manager) GetRecentSessions(limit int) ([]*SessionRecord, int, error) { +// GetRecentSessions returns the most recent sessions. +// +// status filters on SessionRecord.Status ("active" / "closed"); an empty string +// means no filtering. The filter is applied DURING the cursor walk, before the +// limit truncates the result, so a session that started long ago but is still +// active is never dropped by a page full of newer sessions. Filtering after +// truncation would let the tray report "no connected clients" while a client +// is actively calling tools. +func (m *Manager) GetRecentSessions(limit int, status string) ([]*SessionRecord, int, error) { m.mu.RLock() defer m.mu.RUnlock() @@ -1349,20 +1356,43 @@ func (m *Manager) GetRecentSessions(limit int) ([]*SessionRecord, int, error) { return nil // No sessions yet } - // Count total - total = bucket.Stats().KeyN + if status == "" { + // Unfiltered: total is the whole bucket and the walk can stop early. + total = bucket.Stats().KeyN + + // Iterate in reverse (newest first due to timestamp key prefix) + c := bucket.Cursor() + count := 0 + for k, v := c.Last(); k != nil && count < limit; k, v = c.Prev() { + var session SessionRecord + if err := json.Unmarshal(v, &session); err != nil { + m.logger.Warnw("Failed to unmarshal session", "error", err) + continue + } + sessions = append(sessions, &session) + count++ + } - // Iterate in reverse (newest first due to timestamp key prefix) + return nil + } + + // Filtered: walk the whole bucket so `total` honestly counts matching + // records. Session retention caps this bucket at 100 keys + // (enforceSessionRetention), so a full walk is bounded and cheap. c := bucket.Cursor() - count := 0 - for k, v := c.Last(); k != nil && count < limit; k, v = c.Prev() { + for k, v := c.Last(); k != nil; k, v = c.Prev() { var session SessionRecord if err := json.Unmarshal(v, &session); err != nil { m.logger.Warnw("Failed to unmarshal session", "error", err) continue } - sessions = append(sessions, &session) - count++ + if session.Status != status { + continue + } + total++ + if len(sessions) < limit { + sessions = append(sessions, &session) + } } return nil diff --git a/internal/storage/sessions_filter_test.go b/internal/storage/sessions_filter_test.go new file mode 100644 index 000000000..770cbb220 --- /dev/null +++ b/internal/storage/sessions_filter_test.go @@ -0,0 +1,82 @@ +package storage + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A session that started long ago but is still active must survive a small +// page. Storage walks newest-first by START time and truncates to `limit`, so +// filtering on the client after truncation would drop it entirely — the tray's +// "Clients" section would then say "No connected clients" while a client is +// actively calling tools. +func TestGetRecentSessions_StatusFilterAppliedBeforeTruncation(t *testing.T) { + manager, cleanup := setupTestStorageForActivity(t) + defer cleanup() + + base := time.Date(2026, 7, 29, 10, 0, 0, 0, time.UTC) + + // Oldest session is the active one. + require.NoError(t, manager.CreateSession(&SessionRecord{ + ID: "session-old-active", + ClientName: "claude-code", + Status: "active", + StartTime: base, + LastActivity: base.Add(90 * time.Minute), + })) + + // Four newer sessions, all closed. + for i := 1; i <= 4; i++ { + require.NoError(t, manager.CreateSession(&SessionRecord{ + ID: "session-closed-" + string(rune('a'+i-1)), + ClientName: "cursor", + Status: "closed", + StartTime: base.Add(time.Duration(i) * time.Minute), + LastActivity: base.Add(time.Duration(i) * time.Minute), + })) + } + + t.Run("unfiltered page of 2 misses the active session", func(t *testing.T) { + sessions, total, err := manager.GetRecentSessions(2, "") + require.NoError(t, err) + assert.Equal(t, 5, total, "unfiltered total is the whole bucket") + require.Len(t, sessions, 2) + for _, s := range sessions { + assert.Equal(t, "closed", s.Status) + } + }) + + t.Run("status=active returns the old active session despite the page size", func(t *testing.T) { + sessions, total, err := manager.GetRecentSessions(2, "active") + require.NoError(t, err) + assert.Equal(t, 1, total, "total counts matching records, not the bucket") + require.Len(t, sessions, 1) + assert.Equal(t, "session-old-active", sessions[0].ID) + }) + + // The filtered walk must still honour `limit`. With more matches than the + // page size, dropping the truncation guard would return all four — so this + // is the case that actually pins it. Keys are "{startUnixNano}_{id}" and the + // cursor runs Last()->Prev(), so the page is the two NEWEST closed sessions. + t.Run("limit truncates the filtered result while total counts every match", func(t *testing.T) { + sessions, total, err := manager.GetRecentSessions(2, "closed") + require.NoError(t, err) + assert.Equal(t, 4, total, "total counts every match, not just the returned page") + require.Len(t, sessions, 2, "limit must truncate the filtered walk") + assert.Equal(t, "session-closed-d", sessions[0].ID) + assert.Equal(t, "session-closed-c", sessions[1].ID) + }) + + t.Run("status=closed returns only closed sessions", func(t *testing.T) { + sessions, total, err := manager.GetRecentSessions(10, "closed") + require.NoError(t, err) + assert.Equal(t, 4, total) + require.Len(t, sessions, 4) + for _, s := range sessions { + assert.Equal(t, "closed", s.Status) + } + }) +} diff --git a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift index 2b7ed4bf8..6799a613e 100644 --- a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift +++ b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift @@ -336,7 +336,12 @@ actor APIClient { // MARK: - Sessions /// MCP session model from `GET /api/v1/sessions`. - struct MCPSession: Codable, Identifiable { + /// + /// `Equatable` (synthesised — all ten stored properties are Equatable value + /// types) so `AppState.updateGlanceSessions` can guard on the whole value. + /// The tray's Clients rows render a live `toolCallCount` and `lastActivity`, + /// which an id-only guard would freeze at the first poll's numbers. + struct MCPSession: Codable, Identifiable, Equatable { var id: String let clientName: String? let clientVersion: String? @@ -346,7 +351,10 @@ actor APIClient { let toolCallCount: Int? let totalTokens: Int? let startTime: String? - let lastActive: String? + /// Timestamp of the session's most recent activity. The API field is + /// `last_activity` (Go `contracts.MCPSession.LastActivity`); decoding + /// `last_active` silently produced nil for every session. + let lastActivity: String? enum CodingKeys: String, CodingKey { case id @@ -358,7 +366,7 @@ actor APIClient { case toolCallCount = "tool_call_count" case totalTokens = "total_tokens" case startTime = "start_time" - case lastActive = "last_active" + case lastActivity = "last_activity" } } @@ -375,6 +383,18 @@ actor APIClient { return response.sessions } + /// Fetch only currently-active MCP sessions, for the tray glance "Clients" rows. + /// + /// The `status` filter is applied server-side during the storage cursor walk, + /// before truncation — a client-side filter over a page would miss a session + /// that started long ago but is calling tools right now. + func activeSessions(limit: Int = 25) async throws -> [MCPSession] { + let response: SessionsResponse = try await fetchWrapped( + path: "/api/v1/sessions?status=active&limit=\(limit)" + ) + return response.sessions + } + // MARK: - Activity /// Fetch recent activity entries from `GET /api/v1/activity`. @@ -383,6 +403,40 @@ actor APIClient { return response.activities } + /// Fetch the tray glance activity feed from `GET /api/v1/activity`. + /// + /// Separate from `recentActivity(limit:)` on purpose: this one carries the + /// tool-call `type` filter, while `recentActivity` feeds the native Dashboard, + /// which renders the FULL log (security scans, quarantine changes, OAuth). + /// The page is deliberately the server's maximum — management built-ins are + /// filtered client-side, so a smaller page can be filled entirely by proxy + /// admin calls and leave the menu claiming there are no tool calls. The + /// endpoint clamps `limit` to 100; see `AppState.glanceActivityPageSize` + /// for why the tray takes one deep page rather than paging. + /// + /// `exclude_payloads=true` is what makes that page affordable. A full record + /// carries `arguments`, `response` and `metadata`, none of which the glance + /// renders and only one of which is truncated (at 64KB); measured against a + /// real activity log, the newest 100 matching records are ~848KB whole and + /// ~30KB projected — a 28x saving on a request the tray repeats every 30 + /// seconds. `recentActivity(limit:)` deliberately does NOT set it: the + /// Dashboard renders exactly those fields. + func glanceActivity(limit: Int = AppState.glanceActivityPageSize) async throws -> [ActivityEntry] { + let response: ActivityListResponse = try await fetchWrapped( + path: "/api/v1/activity?type=tool_call,internal_tool_call&limit=\(limit)&exclude_payloads=true" + ) + return response.activities + } + + /// Fetch the usage aggregate from `GET /api/v1/activity/usage`. + /// + /// Served from an in-memory snapshot behind a short TTL cache — never a log + /// scan. `top` trims the per-tool rollup the tray does not render; the + /// timeline is global and unaffected by it. + func usageAggregate(window: String = "24h", top: Int = 1) async throws -> UsageAggregateResponse { + return try await fetchWrapped(path: "/api/v1/activity/usage?window=\(window)&top=\(top)") + } + /// Fetch the activity summary from `GET /api/v1/activity/summary`. func activitySummary() async throws -> ActivitySummary { return try await fetchWrapped(path: "/api/v1/activity/summary") @@ -610,6 +664,17 @@ actor APIClient { // MARK: - Private Helpers + /// Detects the standard response envelope without caring about its payload. + /// + /// It answers "does this body carry a `success` field", not "is this body an + /// envelope": a bare payload that happens to have its own `success` field + /// probes as enveloped. That misclassification is reachable and harmless — + /// it only chooses which of two decoding errors is reported in a diagnostic + /// string, on a path where the body has already failed to decode both ways. + private struct EnvelopeProbe: Decodable { + let success: Bool + } + /// Fetch a resource wrapped in the standard `APIResponse` envelope. private func fetchWrapped(path: String) async throws -> T { let (data, _) = try await performRequest(path: path, method: "GET") @@ -622,12 +687,20 @@ actor APIClient { throw APIClientError.httpError(statusCode: 200, message: wrapper.error ?? "Unknown error") } catch let error as APIClientError { throw error - } catch { + } catch let envelopeError { // Try decoding directly without the wrapper (some endpoints don't wrap) do { return try decoder.decode(T.self, from: data) - } catch { - throw APIClientError.decodingError(underlying: error) + } catch let bareError { + // Both decodes failed, so one of the two errors is noise. For an + // enveloped body the envelope error describes the real problem + // inside `data` (a model's own throwing decoder, say), and the + // fallback only re-fails because T's keys are one level down — + // reporting that would mask the cause. For a genuinely unwrapped + // body it is the other way round, which is the pre-existing + // behaviour and stays untouched. + let isEnveloped = (try? decoder.decode(EnvelopeProbe.self, from: data)) != nil + throw APIClientError.decodingError(underlying: isEnveloped ? envelopeError : bareError) } } } diff --git a/native/macos/MCPProxy/MCPProxy/API/Models.swift b/native/macos/MCPProxy/MCPProxy/API/Models.swift index 7a9e909fb..5e09d5cc3 100644 --- a/native/macos/MCPProxy/MCPProxy/API/Models.swift +++ b/native/macos/MCPProxy/MCPProxy/API/Models.swift @@ -1054,3 +1054,94 @@ struct SearchToolsResponse: Codable { let tools: [SearchTool]? let total: Int? } + +// MARK: - Usage Aggregate (Spec 069 A3) + +/// One hourly bar of the usage timeline. +/// Matches Go `contracts.UsageTimeBucket`. +/// +/// `calls` INCLUDES `errors` — a stacked chart must plot `calls - errors` and +/// `errors`, never the two raw fields, or failures are counted twice. +/// Buckets are UTC-hour aligned and sparse: hours with no activity are omitted. +struct UsageBucket: Codable, Equatable { + /// Start of the UTC hour this bucket covers. + let start: Date + let calls: Int + let errors: Int + let totalRespBytes: Int + + enum CodingKeys: String, CodingKey { + case start, calls, errors + case totalRespBytes = "total_resp_bytes" + } +} + +// The Codable conformance lives in an extension so the memberwise initialiser +// is still synthesised (an `init` in the struct body would suppress it). +extension UsageBucket { + /// The API emits Go's RFC 3339 rendering (`2026-07-29T13:00:00Z`), which the + /// shared `JSONDecoder` in `fetchWrapped` cannot parse with its default + /// `.deferredToDate` strategy. Parsing here keeps the model self-contained + /// instead of forcing a decoder-wide date strategy onto every other model. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let raw = try container.decode(String.self, forKey: .start) + guard let parsed = UsageBucket.parseRFC3339(raw) else { + throw DecodingError.dataCorruptedError( + forKey: .start, in: container, + debugDescription: "Not an RFC 3339 timestamp: \(raw)" + ) + } + let calls = try container.decode(Int.self, forKey: .calls) + let errors = try container.decode(Int.self, forKey: .errors) + let bytes = try container.decode(Int.self, forKey: .totalRespBytes) + self.init(start: parsed, calls: calls, errors: errors, totalRespBytes: bytes) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(UsageBucket.rfc3339String(from: start), forKey: .start) + try container.encode(calls, forKey: .calls) + try container.encode(errors, forKey: .errors) + try container.encode(totalRespBytes, forKey: .totalRespBytes) + } + + /// Parse an RFC 3339 timestamp, with or without fractional seconds. + static func parseRFC3339(_ value: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: value) { return date } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: value) + } + + /// Render a date the way the API renders bucket starts. + static func rfc3339String(from date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: date) + } +} + +/// Response for `GET /api/v1/activity/usage`. +/// Matches Go `contracts.UsageAggregateResponse`. +/// +/// The per-tool rollup (`tools`, `other`) and `generated_at` are deliberately not +/// decoded: the tray requests `top=1` and renders only the timeline plus the +/// tokens-saved headline. Unknown keys are ignored by `JSONDecoder`. +struct UsageAggregateResponse: Codable, Equatable { + let window: String + let tokenSource: String? + let tokensSaved: Int? + let tokensSavedPercentage: Double? + /// Global hourly buckets, trimmed to `window`. Never nil; empty when idle. + let timeline: [UsageBucket] + + enum CodingKeys: String, CodingKey { + case window, timeline + case tokenSource = "token_source" + case tokensSaved = "tokens_saved" + case tokensSavedPercentage = "tokens_saved_percentage" + } +} diff --git a/native/macos/MCPProxy/MCPProxy/API/SSEClient.swift b/native/macos/MCPProxy/MCPProxy/API/SSEClient.swift index 293e563b9..37fae6b1a 100644 --- a/native/macos/MCPProxy/MCPProxy/API/SSEClient.swift +++ b/native/macos/MCPProxy/MCPProxy/API/SSEClient.swift @@ -131,7 +131,21 @@ struct SSEParser { /// print(event.event, event.data) /// } /// ``` -actor SSEClient { +/// The part of `SSEClient` that `CoreProcessManager` consumes. +/// +/// Exists so the manager's stream wiring can be driven by a test. That wiring +/// carries a correctness rule — the connection generation is captured when the +/// stream OPENS, never when an event arrives — and a rule that only a comment +/// protects is one a later edit can undo silently. Extracting +/// `SSEStreamSession` made the rule itself testable but left the wiring that +/// applies it unpinned: reinstating the arrival-time read inside +/// `startSSEStream` kept every test green. +protocol SSEStreaming: Sendable { + func connect() async -> AsyncStream + func disconnect() async +} + +actor SSEClient: SSEStreaming { private var task: Task? private let session: URLSession private let baseURL: String diff --git a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift index 168578c87..854f61fc8 100644 --- a/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift +++ b/native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift @@ -44,7 +44,7 @@ actor CoreProcessManager { // Safe because APIClient is an actor — all its methods are isolated get async { await apiClient } } - private var sseClient: SSEClient? + private var sseClient: (any SSEStreaming)? private var sseTask: Task? private var refreshTask: Task? /// Poll for an external core while core autostart is off (GH #410). @@ -1238,17 +1238,36 @@ actor CoreProcessManager { // MARK: - Private: SSE Streaming + /// Install the stream source. Production sets this in `connectToCore`; the + /// only other caller is the test that drives `startSSEStream` end to end, + /// which is what pins the generation capture to the real wiring rather than + /// to `SSEStreamSession` alone. + func installSSEClient(_ client: any SSEStreaming) { + sseClient = client + } + /// Start consuming the SSE event stream. - private func startSSEStream() { + /// + /// Internal rather than private so that test can exist. Everything it does + /// is one call to `SSEStreamSession`; the reason that matters is in the + /// comment inside. + func startSSEStream() { guard let sseClient else { return } sseTask?.cancel() sseTask = Task { [weak self] in - let stream = await sseClient.connect() - for await event in stream { - guard !Task.isCancelled else { break } - await self?.handleSSEEvent(event) - } + // The generation is captured where the STREAM opens, not where an + // event arrives — a stream belongs to exactly one connection, and an + // arrival-time read after a reconnect returns the new generation and + // passes the guard it was meant to fail. That rule lives in + // SSEStreamSession so it is testable; keep this body a single call. + await SSEStreamSession.run( + captureGeneration: { await self?.currentConnectionGeneration() }, + open: { await sseClient.connect() }, + handle: { event, generation in + await self?.handleSSEEvent(event, generation: generation) + } + ) // Stream ended -- trigger reconnection if still connected guard !Task.isCancelled else { return } await self?.handleSSEDisconnect() @@ -1261,7 +1280,15 @@ actor CoreProcessManager { /// We must NOT re-fetch the full server list on each one — that would /// trigger @Published updates which cause MenuBarExtra to duplicate items. /// Instead, only update lightweight counters from the inline status data. - private func handleSSEEvent(_ event: SSEEvent) async { + /// The generation of the connection the tray is on right now. + private func currentConnectionGeneration() async -> Int { + await MainActor.run { appState.connectionGeneration } + } + + /// - Parameter generation: the connection whose stream delivered this event, + /// captured when that stream was opened. Glance publishes are rejected if + /// the tray has since reconnected. + private func handleSSEEvent(_ event: SSEEvent, generation: Int) async { switch event.event { case "status": // Status events contain inline stats. Spec 048: any change that @@ -1319,24 +1346,23 @@ actor CoreProcessManager { appState.activityVersion += 1 } - case "activity": - // New activity; refresh and check for sensitive data - let oldSensitive = await MainActor.run { appState.sensitiveDataAlertCount } - await refreshActivity() - await MainActor.run { appState.activityVersion += 1 } - let newSensitive = await MainActor.run { appState.sensitiveDataAlertCount } - // Notify on new sensitive data detections - if newSensitive > oldSensitive { - if let latest = await MainActor.run(body: { - appState.recentActivity.first(where: { $0.hasSensitiveData == true }) - }) { - await notificationService.sendSensitiveDataAlert( - server: latest.serverName ?? "unknown", - tool: latest.toolName ?? "unknown", - category: "sensitive data" - ) - } + case GlanceEvent.upstreamCompleted, GlanceEvent.internalCompleted: + // Tray Glance: adapt the payload into a row and prepend it. + // Deliberately NO refreshActivity() here — a REST GET per event is + // network amplification, not push. The 30s reconciling poll + // (refreshGlanceActivity) replaces these optimistic rows with the + // storage-assigned records. `activity.tool_call.started` is ignored: + // the core does not persist started events, so a row built from one + // would never be reconciled. `activityVersion` is deliberately NOT + // bumped either — ActivityView reloads on that counter + // (ActivityView.swift → loadSummary + loadActivities), so a + // per-event bump is the same amplification through a second door + // whenever the Activity window is open. + guard let data = event.data.data(using: .utf8), + let entry = GlanceEvent.adapt(eventName: event.event, data: data) else { + break } + await appState.prependGlanceActivity(entry, generation: generation) case "active_profile.changed": // Profiles v2 T5: the server-level default active profile was switched @@ -1505,11 +1531,17 @@ actor CoreProcessManager { private func refreshState() async { await refreshActivity() await refreshSessions() + await refreshGlanceActivity() + await refreshGlanceSessions() + await refreshUsage() await refreshTokenMetrics() await refreshSecurityStatus() await refreshProfiles() - // Bump activityVersion so ActivityView reloads - // (SSE doesn't emit "activity" events, so periodic refresh is needed) + // Bump activityVersion so ActivityView reloads. Still needed after the + // glance's SSE work: the bus emits `activity.tool_call.completed` and + // `activity.internal_tool_call.completed` (internal/runtime/events.go), + // which feed the glance rows only — there is no bare "activity" event, + // and nothing else republishes the full log ActivityView renders. await MainActor.run { appState.activityVersion += 1 } } @@ -1617,6 +1649,37 @@ actor CoreProcessManager { } } + /// Tray Glance: fetch the type-filtered tool-call feed for the menu's + /// "Recent" rows. Separate from `refreshActivity()` on purpose — that feed + /// stays broad because the native Dashboard renders the full activity log. + /// + /// The body lives on `AppState` behind `GlanceDataSource`, like the other + /// two glance fetches: a catch block reachable only from here is a catch + /// block no test can see, which is how this one came to swallow its errors. + private func refreshGlanceActivity() async { + guard let apiClient else { return } + await appState.refreshGlanceActivity(from: apiClient) + } + + /// Tray Glance: fetch active-only sessions for the menu's "Clients" rows. + /// Separate from `refreshSessions()`, which must keep closed sessions so + /// ActivityView can resolve session ids to client names. + private func refreshGlanceSessions() async { + guard let apiClient else { return } + await appState.refreshGlanceSessions(from: apiClient) + } + + /// Tray Glance: fetch the 24h usage aggregate that backs both the header + /// count and the histogram submenu. + /// Non-fatal, and retried on the next refresh — but a failure is RECORDED + /// rather than swallowed. Silently keeping the loading state would tell the + /// user a fetch that is never coming back is still in flight; `refreshUsage` + /// publishes the failure so the submenu can say so. + private func refreshUsage() async { + guard let apiClient else { return } + await appState.refreshUsage(from: apiClient) + } + /// Fetch token metrics from the status endpoint and update appState. private func refreshTokenMetrics() async { guard let apiClient else { return } diff --git a/native/macos/MCPProxy/MCPProxy/Core/SSEStreamSession.swift b/native/macos/MCPProxy/MCPProxy/Core/SSEStreamSession.swift new file mode 100644 index 000000000..9a304c700 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Core/SSEStreamSession.swift @@ -0,0 +1,45 @@ +// SSEStreamSession.swift +// MCPProxy +// +// One run of one SSE stream, on behalf of one connection. + +import Foundation + +/// Consumes an SSE stream and tags every event with the connection it belongs to. +/// +/// This exists as its own type for one reason: WHERE the connection generation +/// is captured is a correctness decision, and it was previously made inside a +/// `Task` closure in `CoreProcessManager` that no test could reach. A test that +/// calls the publish helper with an old generation proves the guard works and +/// says nothing about the capture — it passes just as happily against the +/// broken version that reads the generation when the event arrives, which after +/// a reconnect is the NEW generation and sails through the guard. +/// +/// The rule this type owns: capture once, before the stream is even opened, and +/// hand that same value to every event the stream ever delivers. +enum SSEStreamSession { + + /// Run one stream to completion. + /// + /// - Parameters: + /// - captureGeneration: read ONCE, before `open`, so that a reconnect + /// during the connect itself invalidates this session rather than being + /// silently adopted by it. Returning nil abandons the session (the owner + /// has gone away). + /// - open: produces the stream. Called after the generation is captured. + /// - handle: receives each event together with the generation captured at + /// the top. Never the generation current at delivery time. + static func run( + captureGeneration: () async -> Int?, + open: () async -> AsyncStream?, + handle: (SSEEvent, Int) async -> Void + ) async { + guard let generation = await captureGeneration() else { return } + guard let stream = await open() else { return } + + for await event in stream { + guard !Task.isCancelled else { break } + await handle(event, generation) + } + } +} diff --git a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift index e1e41353c..b787cb08c 100644 --- a/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift +++ b/native/macos/MCPProxy/MCPProxy/MCPProxyApp.swift @@ -24,6 +24,47 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS private var cancellables = Set() private var keyMonitor: Any? + /// Tray Glance: builds the activity / clients / histogram rows, and keeps + /// references to them so a refresh landing while the menu is on screen can + /// rewrite them in place instead of restructuring the menu. Rows call back + /// into this delegate (see `openActivityForSession`) so Web UI key handling + /// stays in one place — the section is handed only AppState, which has no key. + /// + /// `@MainActor` because `GlanceSection` is an isolated type and this class is + /// not, so constructing it from a plain stored-property initializer would not + /// compile. See that type's header for why the alternative — a `nonisolated` + /// initializer over there — is the wrong trade. + @MainActor + private lazy var glance = GlanceSection( + target: self, + action: #selector(openActivityForSession(_:)) + ) + + /// Suppresses structural rebuilds while the menu is on screen. + private var rebuildGuard = MenuRebuildGuard() + + /// Scheduler for the debounced `objectWillChange -> rebuildMenu()` sink. + /// + /// **Must be a dispatch queue, not `RunLoop.main`.** While an `NSMenu` is + /// tracking, the main run loop runs in `NSEventTrackingRunLoopMode`, and + /// Combine's `RunLoop` scheduler installs its timers in `.default` mode + /// only — so a `RunLoop.main`-scheduled `debounce` is never serviced until + /// the menu closes. That silently disables the entire open-menu update + /// path: `GlanceSection.updateInPlace` and `MenuRebuildGuard`'s + /// update-in-place / defer-until-close branches can only run if something + /// calls `rebuildMenu()` while the menu is up, and this sink is the only + /// caller that does. The glance would then be a snapshot frozen at + /// menu-open time, which is precisely what it must not be. + /// + /// The main dispatch queue is drained in every run-loop mode, so this + /// delivers during tracking. It is the same reason the timers below are + /// published `in: .common` rather than in the default mode. + /// + /// Named (rather than written inline at the subscription) so the tests can + /// bind to the very scheduler production uses — see + /// `MenuRefreshSchedulerTests`. + static let menuRefreshScheduler = DispatchQueue.main + func applicationWillFinishLaunching(_ notification: Notification) { // Prevent focus steal on launch — no Dock icon, no Cmd+Tab entry NSApp.setActivationPolicy(.prohibited) @@ -115,7 +156,7 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS appState.objectWillChange.map { _ in () }, updateService.objectWillChange.map { _ in () } ) - .debounce(for: .milliseconds(500), scheduler: RunLoop.main) + .debounce(for: .milliseconds(500), scheduler: Self.menuRefreshScheduler) .sink { [weak self] _ in self?.updateStatusIcon() self?.rebuildMenu() @@ -190,10 +231,41 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS // MARK: - NSMenuDelegate func menuWillOpen(_ menu: NSMenu) { + // Only the status-bar menu drives the rebuild guard. NSMenuDelegate + // callbacks are delivered per menu, and this object is the delegate of + // more than one: any submenu that ends up sharing it would otherwise run + // a full rebuild (removeAllItems) on a menu already on screen and + // re-arm/disarm the guard under the parent — exactly the + // restructuring-while-open the design forbids. + // + // The glance histogram submenu does build its single row lazily on open, + // but it does NOT reach this method: GlanceSection installs its own + // HistogramSubmenuDelegate on that submenu, precisely so opening it + // cannot rebuild the tray menu underneath the cursor. + guard menu === statusItem.menu else { return } + // Spec 048: dropped the per-click `client.servers()` fetch. appState // is fed by SSE (spec 047), so it's already current within ~50 ms of // the last upstream state change. Rebuild from in-memory state only. + // + // The guard is armed AFTER this rebuild: AppKit calls menuWillOpen + // before the menu is drawn, so restructuring here is safe and hands the + // user fresh rows. Every rebuild from this point on happens under the + // cursor and must not add or remove items. rebuildMenu() + rebuildGuard.menuWillOpen() + } + + func menuDidClose(_ menu: NSMenu) { + guard menu === statusItem.menu else { return } + + // Run the structural rebuild that was suppressed while the menu was up. + // Deferred to the next run-loop turn: AppKit is still tearing the menu + // down inside this callback, and mutating it here is not safe. + guard rebuildGuard.menuDidClose() else { return } + DispatchQueue.main.async { [weak self] in + self?.rebuildMenu() + } } func applicationWillTerminate(_ notification: Notification) { @@ -520,7 +592,29 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS /// Rebuild the entire NSMenu from current appState. /// Clears and rebuilds in-place to avoid replacing the menu object /// (which would close an already-open menu and lose the delegate). + /// + /// `@MainActor` because Tray Glance rewrites menu items that are already on + /// screen, which is only safe on the thread AppKit draws them on. Every + /// caller was already main-thread — the NSMenuDelegate/NSApplicationDelegate + /// callbacks are isolated by the SDK and the objectWillChange sink debounces + /// on `menuRefreshScheduler` (the main queue) — so this costs nothing today + /// and rejects a future off-main caller at compile time (see the note on + /// `MenuRebuildGuard.decide(refreshing:from:now:)` for how far that reaches + /// under this package's concurrency checking). + @MainActor private func rebuildMenu() { + // Tray Glance: while the menu is on screen its structure must not move + // under the cursor. The rows are rewritten in place; a structural change + // is suppressed here and re-run from menuDidClose. Non-glance sections + // are frozen for the same reason, and menuWillOpen rebuilds the whole + // menu before it is drawn, so the next open is never stale. + switch rebuildGuard.decide(refreshing: glance, from: appState) { + case .updateInPlace, .deferUntilClose: + return + case .rebuild: + break + } + let menu: NSMenu if let existing = statusItem.menu { existing.removeAllItems() @@ -590,6 +684,15 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS menu.addItem(.separator()) + // Tray Glance — recent tool calls, connected clients, 24h histogram. + // Hidden entirely when the core is not connected: `items(for:)` returns + // [] and this loop adds nothing. GlanceSection keeps references to the + // rows it hands back, so a later in-place update writes to these very + // items — which is why they are added, not copied. + for row in glance.items(for: appState) { + menu.addItem(row) + } + // Needs Attention — only auth required, connection errors, quarantine (NOT disabled) let attentionServers = appState.serversNeedingAttention if !attentionServers.isEmpty { @@ -995,6 +1098,24 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS } } + /// Open the Web UI activity log filtered by a glance row's session. + /// + /// Reuses `openWebUI()`'s key path: `webUIBaseURL` is scheme/host/port only + /// and a first-time browser session needs the API key appended, which only + /// the core manager holds. A row with no session id (an empty-state row, or + /// a record the core never attributed) opens the unfiltered log. + @objc private func openActivityForSession(_ sender: NSMenuItem) { + let sessionID = sender.representedObject as? String + Task { + let apiKey = await coreManager?.currentAPIKey ?? "" + let baseURL = await MainActor.run { appState.webUIBaseURL } + let urlString = activityURLString(baseURL: baseURL, apiKey: apiKey, sessionID: sessionID) + if let url = URL(string: urlString) { + NSWorkspace.shared.open(url) + } + } + } + @objc private func openConfigFile() { let home = FileManager.default.homeDirectoryForCurrentUser NSWorkspace.shared.open(home.appendingPathComponent(".mcpproxy/mcp_config.json")) @@ -1005,6 +1126,7 @@ final class AppController: NSObject, NSApplicationDelegate, NSWindowDelegate, NS NSWorkspace.shared.open(home.appendingPathComponent("Library/Logs/mcpproxy")) } + @MainActor @objc private func toggleAutoStart(_ sender: NSMenuItem) { do { if appState.autoStartEnabled { diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift new file mode 100644 index 000000000..00b7028ff --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/ActivityHistogramView.swift @@ -0,0 +1,344 @@ +// ActivityHistogramView.swift +// MCPProxy +// +// The 24-hour calls-per-hour bar chart shown in the tray glance's +// "Activity (24h)" submenu, plus the pure bucket-shaping and accessibility +// helpers it renders from. +// +// The chart renders from `AppState.usageTimeline` only — opening the submenu +// performs no network request (spec 048 invariant). + +import SwiftUI +import Charts +import AppKit + +// MARK: - Bar model + +/// One hour of the 24-hour histogram, already split into the two stacked +/// segments the chart draws. +/// +/// A `UsageBucket`'s `calls` ALREADY includes its `errors`, so stacking the raw +/// fields would draw every failure twice. `succeeded` is the difference. +struct HistogramBar: Identifiable, Equatable { + /// Start of the UTC hour this bar covers. + let hourStart: Date + /// Calls that did not fail: `calls - errors`, never negative. + let succeeded: Int + /// Calls that failed. + let errors: Int + + var id: Date { hourStart } + + /// Total calls in the hour — the height of the stacked bar. + var total: Int { succeeded + errors } +} + +// MARK: - What the submenu shows + +/// What the histogram submenu renders right now. +/// +/// `loading` and `failed` are deliberately distinct: both leave the timeline +/// nil, and telling the user "Loading…" forever after a failed fetch is the +/// kind of quiet lie this menu must not tell. +enum HistogramState: Equatable { + /// No timeline yet, and no failure recorded. + case loading + /// The usage refresh failed before any timeline arrived; payload is the message. + case failed(String) + /// A full 24-hour axis, oldest hour first. An all-zero axis is a valid + /// loaded state — the proxy was simply idle. + case loaded([HistogramBar]) +} + +// MARK: - Pure helpers + +/// Bucket shaping and accessibility copy for the 24-hour histogram. +/// Pure and synchronous, so it is testable without AppKit or a window server. +enum ActivityHistogram { + + /// Bars on the axis. Fixed, so the axis does not resize as traffic starts + /// and stops. + static let hourCount = 24 + + /// Truncate a date to the start of its UTC hour. + /// + /// Delegates to `AppState.floorToHour` rather than reimplementing it: the + /// header count (`AppState.callsInCurrentHour`) and this axis must agree on + /// where an hour begins, and two copies of the rule would be free to drift. + static func floorToHour(_ date: Date) -> Date { + AppState.floorToHour(date) + } + + /// Project a sparse timeline onto a dense 24-hour axis ending at the UTC + /// hour containing `now`, oldest hour first. + /// + /// The endpoint returns only hours that exist, so missing hours are + /// synthesised as zero and buckets older than the axis are dropped. + static func bars(from timeline: [UsageBucket], now: Date) -> [HistogramBar] { + var succeededByHour: [Date: Int] = [:] + var errorsByHour: [Date: Int] = [:] + + for bucket in timeline { + let hour = floorToHour(bucket.start) + let errors = max(0, bucket.errors) + // `calls` includes `errors`; clamp so a malformed bucket where + // errors > calls cannot produce a negative segment. + let succeeded = max(0, bucket.calls - errors) + succeededByHour[hour, default: 0] += succeeded + errorsByHour[hour, default: 0] += errors + } + + let currentHour = floorToHour(now) + return (0.. String { + let totalCalls = bars.reduce(0) { $0 + $1.total } + let totalErrors = bars.reduce(0) { $0 + $1.errors } + guard let first = bars.first, totalCalls > 0 else { + return "Activity over the last 24 hours: no tool calls." + } + + var peak = first + for bar in bars where bar.total > peak.total { peak = bar } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone + formatter.dateFormat = "HH:mm" + + return "Activity over the last 24 hours: \(totalCalls) calls, \(totalErrors) errors. " + + "Busiest hour \(formatter.string(from: peak.hourStart)) with \(peak.total) calls." + } + + /// Decide what the submenu shows. A timeline that has loaded wins over a + /// recorded failure: showing real (if slightly stale) data beats showing an + /// error row. + static func state(timeline: [UsageBucket]?, errorMessage: String?, now: Date) -> HistogramState { + if let timeline { + return .loaded(bars(from: timeline, now: now)) + } + if let errorMessage, !errorMessage.isEmpty { + return .failed(errorMessage) + } + return .loading + } +} + +// MARK: - Chart + +/// The stacked bar chart itself. Rendering only — it fetches nothing. +/// +/// Two `BarMark`s per hour sharing an x value stack automatically; the series +/// are `calls - errors` and `errors`, never the raw fields. +struct ActivityHistogramView: View { + let bars: [HistogramBar] + let accessibilitySummary: String + + var body: some View { + Chart { + ForEach(bars) { bar in + BarMark( + x: .value("Hour", bar.hourStart, unit: .hour), + y: .value("Calls", bar.succeeded) + ) + .foregroundStyle(by: .value("Outcome", "Succeeded")) + + BarMark( + x: .value("Hour", bar.hourStart, unit: .hour), + y: .value("Calls", bar.errors) + ) + .foregroundStyle(by: .value("Outcome", "Errors")) + } + } + // Colour is deliberately NOT the only thing separating the two series, + // matching the rule `GlanceSection.statusTint` states for the rows above. + // + // Three channels, so no single perceptual failure hides an error bar: + // 1. LIGHTNESS — a muted neutral against a saturated red survives + // greyscale and every form of colour-vision deficiency; two hues of + // similar luminance (the old accent-vs-red pair) do not. + // 2. HUE — neutral vs red, unambiguous for protan and deutan alike. + // 3. The visible LEGEND below, which names which series is which. + // + // `Color.accentColor` is gone on purpose: it follows the user's system + // accent, so anyone who had set theirs to red saw two near-identical + // segments. A fixed pair cannot be broken from System Settings. + // + // Neutral bars with red picked out also puts the visual emphasis on + // failures, which is the right emphasis for a monitoring chart. + .chartForegroundStyleScale([ + "Succeeded": Color.secondary, + "Errors": Color(nsColor: .systemRed) + ]) + .chartLegend(.visible) + .chartYAxis { + AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) + } + .chartXAxis { + AxisMarks(values: .stride(by: .hour, count: 6)) { _ in + AxisGridLine() + AxisValueLabel(format: .dateTime.hour()) + } + } + // Height covers the plot AND the legend below it. Sized so the legend + // is additive: it must not buy its place by shrinking 24 bars that are + // already only ~10 pt wide apiece. + .frame(width: 260, height: 116) + .padding(.horizontal, 14) + .padding(.vertical, 8) + // One label for the whole chart: VoiceOver reading 48 unlabelled bar + // marks would be worse than useless. + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(accessibilitySummary)) + } +} + +extension ActivityHistogram { + + /// Size of the hosted chart item, in points. Menu items do not auto-size a + /// hosting view, so the frame is explicit — and it must match the view's + /// own size, or the row grows a band of dead space. 260 + 2*14 = 288 wide, + /// 116 + 2*8 = 132 tall; measured `NSHostingView.fittingSize` agrees, and + /// `testRealChartItemIsSizedAndLabelled` keeps the two in step. + static let chartItemSize = NSSize(width: 288, height: 132) + + /// The submenu's single custom item: an `NSHostingView` wrapping the chart. + /// + /// Custom menu-item views receive mouse events but not keyboard events, so + /// the item is disabled (nothing to activate) and carries the whole series + /// in one accessibility label on the host view. + static func chartMenuItem(bars: [HistogramBar]) -> NSMenuItem { + let summary = accessibilitySummary(bars: bars) + let item = NSMenuItem(title: "Activity (24h)", action: nil, keyEquivalent: "") + item.isEnabled = false + + let host = NSHostingView( + rootView: ActivityHistogramView(bars: bars, accessibilitySummary: summary) + ) + host.frame = NSRect(origin: .zero, size: chartItemSize) + host.setAccessibilityLabel(summary) + item.view = host + return item + } +} + +// MARK: - Submenu delegate + +/// Builds the single row of `GlanceSection`'s "Activity (24h)" submenu when +/// that submenu opens. +/// +/// This is NOT a second submenu — `GlanceSection` owns the item and the menu, +/// and this object only fills it in on demand. It is a separate `NSObject` +/// purely because `NSMenuDelegate` requires `NSObjectProtocol`, which +/// `GlanceSection` (a plain `@MainActor final class`) does not conform to. +/// +/// Building on open — rather than inside `items(for:)` — keeps the chart off +/// the menu's hot path: `rebuildMenu()` runs on every debounced +/// `objectWillChange`, menu open or closed, so building eagerly would construct +/// an `NSHostingView` and render a SwiftUI Chart on every state change, +/// including for a menu nobody has opened. Reading `AppState` at open time also +/// means a timeline that arrives while the menu sits closed is shown on the +/// next open, with no rebuild of the parent menu. +/// +/// It reads `AppState` and nothing else: opening the submenu performs no +/// network request (spec 048 invariant). +/// +/// `NSMenu.delegate` is a WEAK reference, so `GlanceSection` must retain this. +final class HistogramSubmenuDelegate: NSObject, NSMenuDelegate { + + private let appState: AppState + private let chartItemFactory: ([HistogramBar]) -> NSMenuItem + + /// - Parameter chartItemFactory: injected so submenu-structure tests are + /// independent of how the chart itself renders. It defaults to the real + /// chart: the seam this replaced was optional, nothing in production ever + /// set it, and the tray consequently shipped a text row instead of a + /// chart. A default that already works cannot fail that way. + init(appState: AppState, + chartItemFactory: @escaping ([HistogramBar]) -> NSMenuItem = ActivityHistogram.chartMenuItem) { + self.appState = appState + self.chartItemFactory = chartItemFactory + super.init() + } + + // MARK: NSMenuDelegate + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + for item in currentItems() { menu.addItem(item) } + } + + // MARK: Rows + + /// The rows the submenu shows for the current `AppState`: the data row, + /// preceded by a stale marker when the feeds have stopped arriving. + /// + /// `ActivityHistogram.state()` charts a loaded timeline in preference to a + /// recorded failure, which is right for a blip and wrong for a core that is + /// never coming back — the failure is then recorded every 30 seconds and + /// rendered never. The marker is what makes it visible without taking the + /// real (if stale) data off the screen. + /// + /// It is suppressed when the data row is itself the failure row, which + /// already says the same thing. + func currentItems() -> [NSMenuItem] { + let item = currentItem() + guard appState.glanceStale, item.title != Self.unavailableTitle else { return [item] } + + let marker = Self.mutedItem("Not updating") + marker.toolTip = appState.glanceError + return [marker, item] + } + + /// Title of the row shown when the usage fetch failed with nothing loaded. + private static let unavailableTitle = "Usage unavailable" + + /// The single row the submenu shows for the current `AppState`. + /// + /// The clock is read here, at open time, rather than injected: every + /// assertion about this row is structural (which row, how many), and the + /// axis contents `now` decides are covered exhaustively by the pure + /// `ActivityHistogram.bars` tests. + func currentItem() -> NSMenuItem { + switch ActivityHistogram.state( + timeline: appState.usageTimeline, + errorMessage: appState.usageError, + now: Date() + ) { + case .loading: + return Self.mutedItem("Loading…") + case .failed(let message): + let item = Self.mutedItem(Self.unavailableTitle) + item.toolTip = message + return item + case .loaded(let bars): + return chartItemFactory(bars) + } + } + + /// A disabled, secondary-coloured text row. Setting `attributedTitle` + /// leaves `title` intact, so the plain string stays available to tests and + /// to accessibility. + static func mutedItem(_ title: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + item.attributedTitle = NSAttributedString(string: title, attributes: [ + .font: NSFont.menuFont(ofSize: 0), + .foregroundColor: NSColor.secondaryLabelColor + ]) + return item + } +} diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceDataSource.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceDataSource.swift new file mode 100644 index 000000000..2ece20903 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceDataSource.swift @@ -0,0 +1,27 @@ +// GlanceDataSource.swift +// MCPProxy +// +// The narrow read surface the tray glance section depends on. + +import Foundation + +/// The three reads that feed the tray glance section. +/// +/// The glance component depends on this protocol rather than on the concrete +/// `APIClient` actor so a counting stub can be injected in tests. That is what +/// makes the spec-048 invariant testable: opening the menu must perform zero +/// network requests, which can only be asserted if the requests are countable. +protocol GlanceDataSource { + /// `GET /api/v1/activity/usage?window=&top=` + func usageAggregate(window: String, top: Int) async throws -> UsageAggregateResponse + + /// `GET /api/v1/activity?type=tool_call,internal_tool_call&limit=` + func glanceActivity(limit: Int) async throws -> [ActivityEntry] + + /// `GET /api/v1/sessions?status=active&limit=` + func activeSessions(limit: Int) async throws -> [APIClient.MCPSession] +} + +/// The production data source. `APIClient` already has all three methods with +/// matching signatures, so the conformance is declaration-only. +extension APIClient: GlanceDataSource {} diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceEvent.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceEvent.swift new file mode 100644 index 000000000..a1c453475 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceEvent.swift @@ -0,0 +1,90 @@ +// GlanceEvent.swift +// MCPProxy +// +// Adapts `activity.*.completed` SSE payloads into `ActivityEntry` values so the +// tray glance section can render live rows without issuing a REST request per +// event. + +import Foundation + +/// Maps runtime activity SSE payloads onto `ActivityEntry`. +enum GlanceEvent { + /// SSE event name for a completed upstream tool call. + static let upstreamCompleted = "activity.tool_call.completed" + + /// SSE event name for a completed internal (built-in) tool call. + static let internalCompleted = "activity.internal_tool_call.completed" + + /// Build an `ActivityEntry` from an SSE envelope. + /// + /// Returns nil for any other event name (notably + /// `activity.tool_call.started`) and for a payload that does not parse. + static func adapt(eventName: String, data: Data) -> ActivityEntry? { + let type: String + switch eventName { + case upstreamCompleted: type = "tool_call" + case internalCompleted: type = "internal_tool_call" + default: return nil + } + + guard let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let payload = envelope["payload"] as? [String: Any] else { return nil } + + let toolName: String? + let serverName: String? + if type == "tool_call" { + toolName = nonEmptyString(payload["tool_name"]) + serverName = nonEmptyString(payload["server_name"]) + } else { + toolName = nonEmptyString(payload["internal_tool_name"]) + serverName = nonEmptyString(payload["target_server"]) + } + + let requestId = nonEmptyString(payload["request_id"]) + // Composite, not a bare request id: one failing call emits BOTH events + // under a single request id, and `ActivityEntry` derives identity from + // `id` alone, so the two records would collide before + // `GlanceSelection.collapseByRequestID` could choose between them. + let provisionalId = requestId.map { "\($0):\(type)" } ?? "sse-\(UUID().uuidString):\(type)" + + let seconds = (envelope["timestamp"] as? NSNumber)?.doubleValue + ?? Date().timeIntervalSince1970 + let timestamp = isoFormatter.string(from: Date(timeIntervalSince1970: seconds)) + + return ActivityEntry( + id: provisionalId, + type: type, + source: nonEmptyString(payload["source"]), + serverName: serverName, + toolName: toolName, + arguments: nil, + response: nil, + responseTruncated: nil, + status: nonEmptyString(payload["status"]) ?? "success", + errorMessage: nonEmptyString(payload["error_message"]), + durationMs: (payload["duration_ms"] as? NSNumber)?.int64Value, + timestamp: timestamp, + sessionId: nonEmptyString(payload["session_id"]), + requestId: requestId, + metadata: nil, + hasSensitiveData: nil, + detectionTypes: nil, + maxSeverity: nil + ) + } + + private static func nonEmptyString(_ value: Any?) -> String? { + guard let text = value as? String, !text.isEmpty else { return nil } + return text + } + + /// Emits fractional seconds, which `GlanceFormatting.parseTimestamp` + /// accepts on its first attempt (it falls back to a non-fractional parser + /// for polled records). + private static let isoFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() +} diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceFormatting.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceFormatting.swift new file mode 100644 index 000000000..eebb27c85 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceFormatting.swift @@ -0,0 +1,108 @@ +// GlanceFormatting.swift +// MCPProxy +// +// Pure presentation helpers for the tray glance section: status symbol, +// row label, middle truncation, and compact relative age. +// Salvaged from the retired Menu/TrayMenu.swift. + +import Foundation + +/// Pure, AppKit-free formatting helpers for glance rows. +enum GlanceFormatting { + + // MARK: - Status symbol + + /// SF Symbol name for an activity record's outcome. + /// + /// Shape carries the meaning (never colour alone), so a checkmark, a + /// cross and an exclamation mark are three distinct glyphs. + static func statusSymbolName(for entry: ActivityEntry) -> String { + switch entry.status { + case "success": + return "checkmark.circle" + case "error": + return "xmark.circle" + default: + return "exclamationmark.circle" + } + } + + // MARK: - Row label + + /// Compose the row's primary label. + /// + /// Upstream calls read `server:tool`; discovery/execution built-ins read + /// just the built-in's name because they have no upstream server. + static func rowLabel(for entry: ActivityEntry) -> String { + let tool = entry.toolName ?? "" + let server = entry.serverName ?? "" + + if entry.type == "tool_call", !server.isEmpty, !tool.isEmpty { + // Guard against a tool name that already carries the prefix. + if tool.hasPrefix("\(server):") { + return tool + } + return "\(server):\(tool)" + } + if !tool.isEmpty { + return tool + } + if !server.isEmpty { + return server + } + return entry.type + } + + // MARK: - Truncation + + /// Middle-truncate `text` to at most `limit` characters, keeping the head + /// (the server prefix) and the tail (the end of the tool name). + static func middleTruncated(_ text: String, limit: Int) -> String { + guard limit > 0 else { return "" } + guard text.count > limit else { return text } + let keep = limit - 1 // one slot for the ellipsis + let head = keep / 2 + keep % 2 + let tail = keep - head + let prefix = String(text.prefix(head)) + let suffix = tail > 0 ? String(text.suffix(tail)) : "" + return prefix + "\u{2026}" + suffix + } + + // MARK: - Time + + private static let fractionalParser: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + private static let plainParser: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + /// Parse an RFC3339/ISO-8601 timestamp with or without fractional seconds. + static func parseTimestamp(_ timestamp: String) -> Date? { + if let date = fractionalParser.date(from: timestamp) { return date } + return plainParser.date(from: timestamp) + } + + /// Compact, locale-independent age string: `12s`, `3m`, `5h`, `2d`. + static func compactAge(_ seconds: TimeInterval) -> String { + let total = max(0, Int(seconds.rounded())) + if total < 60 { return "\(total)s" } + let minutes = total / 60 + if minutes < 60 { return "\(minutes)m" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)h" } + return "\(hours / 24)d" + } + + /// Relative age of an activity timestamp, falling back to the raw string + /// when it cannot be parsed. + static func relativeTime(_ timestamp: String, now: Date = Date()) -> String { + guard let date = parseTimestamp(timestamp) else { return timestamp } + return compactAge(now.timeIntervalSince(date)) + } +} diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceLinks.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceLinks.swift new file mode 100644 index 000000000..1fdd2c123 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceLinks.swift @@ -0,0 +1,30 @@ +// GlanceLinks.swift +// MCPProxy +// +// Web UI deep links opened from glance rows. + +import Foundation + +/// Build the Web UI activity-log URL, optionally filtered by session. +/// +/// `?session=` is the query parameter the Activity view reads on mount +/// (frontend/src/views/Activity.vue:1334, `route.query.session`), and the Web +/// UI router is history-based (createWebHistory over `base: '/ui/'`), so +/// `/ui/activity` is a real path rather than a fragment. `apikey` travels as a +/// query parameter because a browser cannot send the `X-API-Key` header — +/// `/ui/` is the one surface that accepts it, and the Web UI strips only +/// `apikey` from the address bar on load (services/api.ts:69-80), keeping +/// `session`. +func activityURLString(baseURL: String, apiKey: String, sessionID: String?) -> String { + let path = baseURL + "/ui/activity" + var query: [URLQueryItem] = [] + if let sessionID, !sessionID.isEmpty { + query.append(URLQueryItem(name: "session", value: sessionID)) + } + if !apiKey.isEmpty { + query.append(URLQueryItem(name: "apikey", value: apiKey)) + } + guard var components = URLComponents(string: path) else { return path } + components.queryItems = query.isEmpty ? nil : query + return components.string ?? path +} diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift new file mode 100644 index 000000000..9da2d973b --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSection.swift @@ -0,0 +1,473 @@ +// GlanceSection.swift +// MCPProxy +// +// Builds the "glance" block at the top of the tray menu: a one-line summary, +// the most recent qualifying tool calls, the active MCP clients, and the +// 24h histogram submenu. +// +// Every text row is a plain NSMenuItem. Custom (view-backed) menu items receive +// mouse events but NOT keyboard events, so building the rows as hosted SwiftUI +// would silently cost keyboard navigation and VoiceOver. Only the histogram — +// which genuinely needs drawing — is view-backed, and it lives alone inside its +// own submenu. +// +// This component never builds a Web UI URL. It is handed only AppState, whose +// webUIBaseURL is scheme/host/port by design, while the API key lives on the +// core manager. Rows therefore carry a target/action pair plus a +// representedObject holding the record's session id, and the app delegate opens +// the authenticated URL through the same path as every other menu action. +// +// @MainActor as a type: every member here either builds live NSMenuItems or +// reads AppState, so main-thread-only is the truth about this component, and +// stating it structurally beats relying on rebuildMenu() happening to be the +// sole route in. +// +// That does mean the initializer is main-actor-only too, and AppController (the +// NSApplicationDelegate that owns this, MCPProxyApp.swift:15) is NOT +// actor-isolated — this SDK does not infer MainActor from NSApplicationDelegate +// conformance — so its `private lazy var glance = ...` stored property would +// otherwise fail with "call to main actor-isolated initializer +// 'init(target:action:)' in a synchronous nonisolated context". The fix is one +// @MainActor on that stored property, at the construction site. +// +// Do NOT "simplify" this by marking the initializer `nonisolated` instead. It +// compiles and passes, but assigning the isolated `clickTarget` from a +// nonisolated init warns "main actor-isolated property 'clickTarget' can not be +// mutated from a nonisolated context; this is an error in the Swift 6 language +// mode" — i.e. it buys a glance-local diff today at the cost of a blocker when +// this package moves to Swift 6. clickTarget cannot be a `let` either: it is +// deliberately `weak` (AppController owns the section strongly), and `weak` +// requires `var`. + +import AppKit + +@MainActor +final class GlanceSection { + + // MARK: Click routing + + private weak var clickTarget: AnyObject? + private let clickAction: Selector + + /// Builds the histogram submenu's single custom item from a shaped 24-hour + /// axis. Injected so submenu-structure tests are independent of how the + /// chart renders; it defaults to the real chart, so no wiring step can + /// forget to set it. + var histogramChartItemFactory: ([HistogramBar]) -> NSMenuItem = ActivityHistogram.chartMenuItem + + // MARK: Configuration + + /// Character budget for a row label before middle truncation kicks in. + private static let labelBudget = 34 + + // MARK: Owned items (kept so rows can be rewritten in place) + + /// A built activity row together with the identity of the record it is + /// currently showing, so an update can tell "same record, later clock" from + /// "this row now represents a different call". + private struct ActivityRow { + let item: NSMenuItem + /// The record's key — see `recordKey(for:)`. Nil until first rendered. + var recordKey: String? + /// The SF Symbol currently installed, so the icon is rebuilt only when + /// the glyph really changes. + var symbolName: String? + } + + private var summaryItem: NSMenuItem? + private var activityRows: [ActivityRow] = [] + private var clientRows: [NSMenuItem] = [] + + /// Snapshot of the structure the current items were built from, so an + /// in-place update can detect that a full rebuild is required instead. + private var hasBuilt = false + private var builtVisible = false + + /// Held only so ownership of the submenu is explicit; `updateInPlace` + /// deliberately never touches it (re-creating it would disturb an open + /// submenu), so nothing reads this back. + private var histogramItem: NSMenuItem? + + /// The submenu's delegate, which fills the submenu in when it opens. + /// `NSMenu.delegate` is a WEAK reference, so without this the delegate + /// would deallocate the moment `items(for:)` returned and the submenu would + /// silently open empty forever. + private var histogramDelegate: HistogramSubmenuDelegate? + + init(target: AnyObject?, action: Selector) { + self.clickTarget = target + self.clickAction = action + } + + // MARK: Building + + /// Whether the glance block should appear at all. When the core is stopped + /// or disconnected the block is hidden entirely, rather than presenting the + /// previous core's numbers as if they were live. + func isVisible(for state: AppState) -> Bool { + state.isConnected && !state.isStopped + } + + /// Build the whole block, ordered top to bottom and ending with a separator + /// so the caller can splice it into the menu in one go. Returns an empty + /// array when the block is hidden. + func items(for state: AppState, now: Date = Date()) -> [NSMenuItem] { + summaryItem = nil + activityRows = [] + clientRows = [] + histogramItem = nil + hasBuilt = true + builtVisible = isVisible(for: state) + guard builtVisible else { return [] } + + var items: [NSMenuItem] = [] + + let summary = disabledItem(titled: summaryTitle(for: state)) + summaryItem = summary + items.append(summary) + items.append(.separator()) + + items.append(disabledItem(titled: "Recent")) + let entries = GlanceSelection.activityRows(from: state.glanceActivity) + if entries.isEmpty { + items.append(disabledItem(titled: "No tool calls yet")) + } else { + for entry in entries { + var row = ActivityRow(item: actionableItem()) + apply(entry, to: &row, now: now) + activityRows.append(row) + items.append(row.item) + } + } + + let openActivity = actionableItem() + openActivity.title = "Open Activity…" + openActivity.image = NSImage(systemSymbolName: "list.bullet.rectangle", + accessibilityDescription: "activity log") + items.append(openActivity) + items.append(.separator()) + + items.append(disabledItem(titled: "Clients")) + let clients = GlanceSelection.activeClients(from: state.glanceSessions) + if clients.isEmpty { + items.append(disabledItem(titled: "No connected clients")) + } else { + for session in clients { + let row = actionableItem() + apply(session, to: row, now: now) + clientRows.append(row) + items.append(row) + } + } + items.append(.separator()) + + let histogram = makeHistogramItem(for: state) + histogramItem = histogram + items.append(histogram) + items.append(.separator()) + + return items + } + + // MARK: In-place updates + + /// Rewrite the existing rows from `state` without restructuring the menu. + /// + /// Returns `true` when every row was updated in place, and `false` when the + /// block's structure changed (visibility or row count) — the caller must + /// then defer a full rebuild until the menu closes rather than growing or + /// shrinking a menu the user is reading. + /// + /// When a row comes to stand for a different record its *entire identity* is + /// rewritten, not just its title: with a fixed number of rows every new + /// event shifts which record each row represents, so refreshing only the + /// text would leave a row whose click still opened the previous record's + /// session. See `apply(_:to:now:)` for how "different record" is decided. + /// + /// The histogram submenu is deliberately not touched, and does not need to + /// be: its single row is built by `HistogramSubmenuDelegate` when it opens, + /// reading `AppState` at that moment. Whether the timeline has loaded + /// therefore changes nothing about the structure built here, which is why + /// this no longer reports structural when it flips. + @discardableResult + func updateInPlace(for state: AppState, now: Date = Date()) -> Bool { + guard hasBuilt else { return false } + guard isVisible(for: state) == builtVisible else { return false } + guard builtVisible else { return true } + + let entries = GlanceSelection.activityRows(from: state.glanceActivity) + let clients = GlanceSelection.activeClients(from: state.glanceSessions) + guard entries.count == activityRows.count, + clients.count == clientRows.count else { return false } + + let summary = summaryTitle(for: state) + if summaryItem?.title != summary { summaryItem?.title = summary } + // `zip`, like the sibling loop below: indexing `entries` by + // `activityRows.indices` reads out of bounds if the count guard above is + // ever weakened, and zip cannot. + for (index, entry) in zip(activityRows.indices, entries) { + apply(entry, to: &activityRows[index], now: now) + } + for (row, session) in zip(clientRows, clients) { apply(session, to: row, now: now) } + return true + } + + // MARK: Row rendering + + /// Identity of the record a row is showing: its `requestId`, never its `id`. + /// + /// A row rendered from a live SSE event carries a *provisional* id of the + /// form `":"`, which the 30-second reconciling poll + /// replaces with the storage-assigned ULID for the very same call. Keying on + /// `id` would therefore report a wholesale turnover of every row on every + /// poll, needlessly rewriting five rows' icons and click payloads each time. + /// `requestId` is identical on both sides, and is already what rule 4 + /// (`GlanceSelection.collapseByRequestID`) groups on. Records with no + /// request id are never collapsed, so their `id` is a safe fallback key. + /// Delegates to `GlanceSelection.recordKey` rather than restating the rule: + /// the row diff, rule 4's collapse and `AppState`'s poll/SSE merge must all + /// agree on what "the same call" means, and three copies would be free to + /// drift. + private static func recordKey(for entry: ActivityEntry) -> String { + GlanceSelection.recordKey(for: entry) + } + + /// Rewrite an activity row so its title, icon, tooltip, accessibility label + /// and click payload all describe `entry`. + /// + /// When the row has changed record every one of those is written back + /// unconditionally: with a fixed set of rows each new event shifts which + /// record a row stands for, and a row that kept the previous record's click + /// payload or icon would mislead silently. When it is still the same record + /// — the common case, since the reconcile only re-ids it — only what + /// actually differs is written, so a menu the user is reading is not + /// re-laid-out on every tick. Either way the row ends up fully describing + /// `entry`; the distinction is only how much is written to get there. + private func apply(_ entry: ActivityEntry, to row: inout ActivityRow, now: Date) { + let key = Self.recordKey(for: entry) + let sameRecord = row.recordKey == key + let item = row.item + + let fullLabel = GlanceFormatting.rowLabel(for: entry) + let label = GlanceFormatting.middleTruncated(fullLabel, limit: Self.labelBudget) + let age = GlanceFormatting.relativeTime(entry.timestamp, now: now) + let failed = entry.status != "success" + let detail = failed ? Self.firstClause(of: entry.errorMessage) : nil + + let title: String + let accessibility: String + if let detail { + title = "\(label) · \(detail) — \(age)" + accessibility = "\(fullLabel), failed: \(detail), \(age) ago" + } else { + title = "\(label) — \(age)" + accessibility = "\(fullLabel), \(Self.outcomeDescription(for: entry)), \(age) ago" + } + + let toolTip: String + if let message = entry.errorMessage, !message.isEmpty { + toolTip = "\(fullLabel)\n\(message)" + } else { + toolTip = fullLabel + } + + let symbol = GlanceFormatting.statusSymbolName(for: entry) + + if !sameRecord || item.title != title { item.title = title } + if !sameRecord || item.accessibilityLabel() != accessibility { + item.setAccessibilityLabel(accessibility) + } + if !sameRecord || item.toolTip != toolTip { item.toolTip = toolTip } + if !sameRecord || row.symbolName != symbol { + item.image = Self.statusImage(for: entry) + row.symbolName = symbol + } + if !sameRecord || (item.representedObject as? String) != entry.sessionId { + item.representedObject = entry.sessionId + } + + row.recordKey = key + } + + /// First clause of an error message — everything up to the first newline, + /// period or colon — so a multi-sentence backend error still fits one row. + /// The full message stays in the tooltip. + static func firstClause(of message: String?) -> String? { + guard let message else { return nil } + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let head = trimmed.components(separatedBy: CharacterSet(charactersIn: ".:\n")).first ?? trimmed + let clause = head.trimmingCharacters(in: .whitespaces) + return clause.isEmpty ? trimmed : clause + } + + // MARK: Status iconography + + /// Tint for an activity record's outcome. + /// + /// Colour is the *second* channel, never the only one: `GlanceFormatting` + /// already gives the three outcomes three distinct glyphs, and + /// `outcomeDescription` gives VoiceOver a third. A red/green pair alone + /// would be invisible to the ~8% of men with a red-green deficiency, and to + /// anyone running the display in greyscale. + /// + /// This lives here rather than in `GlanceFormatting` because that file is + /// deliberately AppKit-free (`import Foundation` only) and `NSColor` is not. + static func statusTint(for entry: ActivityEntry) -> NSColor { + switch entry.status { + case "success": + return .systemGreen + case "error": + return .systemRed + default: + return .systemOrange + } + } + + /// Spoken outcome for VoiceOver. Three-valued so a call that is still + /// running is not announced as a failure. + static func outcomeDescription(for entry: ActivityEntry) -> String { + switch entry.status { + case "success": + return "succeeded" + case "error": + return "failed" + default: + return "in progress" + } + } + + /// The row icon: an SF Symbol whose shape carries the outcome, tinted to + /// carry it a second time. + /// + /// The image must be non-template — AppKit recolours a template menu image + /// to the menu's own text colour, which would silently discard the tint. + private static func statusImage(for entry: ActivityEntry) -> NSImage? { + symbolImage(named: GlanceFormatting.statusSymbolName(for: entry), + tint: statusTint(for: entry), + description: outcomeDescription(for: entry)) + } + + private static func symbolImage(named name: String, tint: NSColor, description: String) -> NSImage? { + guard let base = NSImage(systemSymbolName: name, accessibilityDescription: description) else { + return nil + } + let tinted = base.withSymbolConfiguration(NSImage.SymbolConfiguration(paletteColors: [tint])) ?? base + tinted.isTemplate = false + tinted.accessibilityDescription = description + return tinted + } + + /// The client-row bullet. Every client row is an *active* session, so this + /// glyph is a constant — built once rather than re-tinted per row per poll. + private static let connectedDot = symbolImage(named: "circle.fill", + tint: .systemGreen, + description: "connected") + + /// Rewrite a client row so it fully describes `session`. + /// + /// Unlike an activity row this needs no `recordKey`: a session id does not + /// churn, so a row never comes to stand for a different client without the + /// row count changing (which `updateInPlace` already reports as structural). + /// The write guards are still needed, and for a different reason — cost. + /// `updateInPlace` runs on nearly every 30s poll for a busy proxy, under a + /// menu the user may have open, where every write is a re-layout; so only + /// what actually differs is written. + private func apply(_ session: APIClient.MCPSession, to item: NSMenuItem, now: Date) { + let name = session.clientName.flatMap { $0.isEmpty ? nil : $0 } ?? "Unknown client" + let calls = session.toolCallCount ?? 0 + let callText = calls == 1 ? "1 call" : "\(calls) calls" + let age = session.lastActivity.map { GlanceFormatting.relativeTime($0, now: now) } + + let title: String + let accessibility: String + if let age { + title = "\(name) — \(callText) · \(age)" + accessibility = "\(name), \(callText), last active \(age) ago" + } else { + title = "\(name) — \(callText)" + accessibility = "\(name), \(callText)" + } + + let toolTip: String + if let version = session.clientVersion, !version.isEmpty { + toolTip = "\(name) \(version)" + } else { + toolTip = name + } + + if item.title != title { item.title = title } + if item.accessibilityLabel() != accessibility { item.setAccessibilityLabel(accessibility) } + if item.toolTip != toolTip { item.toolTip = toolTip } + if item.image !== Self.connectedDot { item.image = Self.connectedDot } + if (item.representedObject as? String) != session.id { item.representedObject = session.id } + } + + // MARK: Histogram + + /// The "Activity (24h)" item and its (initially empty) submenu. + /// + /// The submenu's single row is built by its delegate when it opens, not + /// here: `items(for:)` runs on every `rebuildMenu()` — which itself runs on + /// every debounced `objectWillChange`, menu open or closed — and building + /// eagerly would render a SwiftUI Chart on every state change, including + /// for a menu nobody has opened. + /// + /// The submenu carries its OWN delegate rather than the tray menu's. That + /// is what keeps opening it off `AppController.menuWillOpen`, which + /// rebuilds the whole menu; a submenu opening under the cursor must not + /// restructure the menu it hangs from. + private func makeHistogramItem(for state: AppState) -> NSMenuItem { + let item = NSMenuItem(title: "Activity (24h)", action: nil, keyEquivalent: "") + let submenu = NSMenu(title: "Activity (24h)") + // Nothing in here is actionable, and AppKit's automatic enabling runs + // its own validation at display time. Turning it off makes the row's + // disabled state ours — and makes what the tests assert the same thing + // the user sees. + submenu.autoenablesItems = false + + let delegate = HistogramSubmenuDelegate(appState: state, + chartItemFactory: histogramChartItemFactory) + histogramDelegate = delegate + submenu.delegate = delegate + + item.submenu = submenu + return item + } + + // MARK: Header + + /// The header line, plus an admission when the numbers in it have stopped + /// arriving. + /// + /// Without the marker a dead core is indistinguishable from a healthy idle + /// one, and worse than frozen: the refresh loop bumps `activityVersion` + /// whether or not any fetch succeeded, so the rows re-render with a fresh + /// clock every 30 seconds and the block ticks along presenting a previous + /// core's numbers as live. A frozen menu is a hint that something is wrong; + /// a ticking one is not. + private func summaryTitle(for state: AppState) -> String { + var parts: [String] = [] + if let calls = state.callsThisHour { + parts.append(calls == 1 ? "1 call this hour" : "\(calls) calls this hour") + } + let clients = GlanceSelection.activeClients(from: state.glanceSessions, limit: Int.max).count + parts.append(clients == 1 ? "1 client" : "\(clients) clients") + if state.glanceStale { parts.append("not updating") } + return parts.joined(separator: " · ") + } + + // MARK: Item factories + + private func disabledItem(titled title: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.isEnabled = false + return item + } + + private func actionableItem() -> NSMenuItem { + let item = NSMenuItem(title: "", action: clickAction, keyEquivalent: "") + item.target = clickTarget + return item + } +} diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift new file mode 100644 index 000000000..fd435b599 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/GlanceSelection.swift @@ -0,0 +1,126 @@ +// GlanceSelection.swift +// MCPProxy +// +// Display rules for the tray glance section: which activity records qualify +// as rows, how duplicates collapse, and which sessions count as clients. +// Pure functions over ActivityEntry / APIClient.MCPSession — no AppKit. + +import Foundation + +/// Presentation policy for the glance section. Pure and synchronous. +enum GlanceSelection { + + /// Proxy administration built-ins. Never shown, whatever their status. + static let managementBuiltIns: Set = ["upstream_servers", "quarantine_security"] + + /// Discovery/execution built-ins that are worth a row even on success. + static let glanceInternalTools: Set = ["retrieve_tools", "code_execution", "describe_tool"] + + /// How many rows each list shows. + static let rowLimit = 5 + + // MARK: - Rules 1-3 + + /// Whether a single record qualifies for a glance row. + static func qualifies(_ entry: ActivityEntry) -> Bool { + let tool = entry.toolName ?? "" + + // Rule 1 — management built-ins are excluded, whatever the status. + if managementBuiltIns.contains(tool) { return false } + + // Rule 2 — every real upstream call. + if entry.type == "tool_call" { return true } + + // Rule 3 — discovery/execution built-ins, plus any internal failure + // (a wrapper that died before dispatch has no upstream record). + if entry.type == "internal_tool_call" { + return glanceInternalTools.contains(tool) || entry.status != "success" + } + + return false + } + + // MARK: - Rule 4 + + /// Collapse records sharing a `request_id`, keeping the `tool_call` one. + /// + /// The surviving record is emitted at the position of the first record of + /// its group so recency ordering is preserved. Records with no request id + /// are never collapsed. When a group holds several `tool_call` records the + /// first one encountered wins — later ones are dropped, not merged. + /// + /// Why this is safe today: the core mints a request id per dispatch + /// (`internal/server/mcp.go`, both `requestID := fmt.Sprintf("%d-%s-%s", …)` + /// sites under "Generate requestID for activity tracking"), so two distinct + /// upstream calls carry distinct ids structurally. A group is therefore a + /// wrapper plus its upstream partner, never a fan-out. + /// + /// What would invalidate that: `internal/server/mcp_code_execution.go` sets + /// `RequestID: u.executionID` ("Use execution ID as request ID to link + /// nested calls") on every nested call a `code_execution` script makes, so + /// all of them share ONE id. Those records are currently invisible here — + /// the nested caller only writes to the legacy history via + /// `storage.RecordToolCall` and emits no activity event. If nested calls + /// are ever wired into the activity stream, this function would collapse an + /// entire multi-tool script down to a single row. At that point rule 4 must + /// narrow to "collapse a wrapper with its upstream partner" rather than + /// deduplicating a whole request id. + static func collapseByRequestID(_ entries: [ActivityEntry]) -> [ActivityEntry] { + var winners: [String: ActivityEntry] = [:] + for entry in entries { + guard let rid = entry.requestId, !rid.isEmpty else { continue } + guard let existing = winners[rid] else { + winners[rid] = entry + continue + } + if existing.type != "tool_call" && entry.type == "tool_call" { + winners[rid] = entry + } + } + + var emitted = Set() + var result: [ActivityEntry] = [] + for entry in entries { + guard let rid = entry.requestId, !rid.isEmpty else { + result.append(entry) + continue + } + if emitted.contains(rid) { continue } + emitted.insert(rid) + result.append(winners[rid] ?? entry) + } + return result + } + + // MARK: - Record identity + + /// Identity of one call: its `requestId`, never its `id`. + /// + /// A row rendered from a live SSE event carries a provisional id of the + /// form `":"`, which the reconciling poll replaces with + /// the storage-assigned ULID for the very same call — so `id` reports a + /// wholesale turnover on every poll while `requestId` is identical on both + /// sides. It is what rule 4 collapses on, what `AppState`'s merge keys on, + /// and what `GlanceSection` diffs rows by. Records with no request id are + /// never collapsed, so their `id` is a safe fallback. + static func recordKey(for entry: ActivityEntry) -> String { + if let requestId = entry.requestId, !requestId.isEmpty { return requestId } + return entry.id + } + + // MARK: - Public entry points + + /// Rules 1-4 applied in order, then the first `limit` survivors. + static func activityRows(from entries: [ActivityEntry], limit: Int = rowLimit) -> [ActivityEntry] { + let qualified = entries.filter(qualifies) + return Array(collapseByRequestID(qualified).prefix(limit)) + } + + /// Sessions currently connected, capped at `limit`, input order preserved. + static func activeClients( + from sessions: [APIClient.MCPSession], + limit: Int = rowLimit + ) -> [APIClient.MCPSession] { + Array(sessions.filter { $0.status == "active" }.prefix(limit)) + } +} diff --git a/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift b/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift new file mode 100644 index 000000000..e877232ba --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Menu/Glance/MenuRebuildGuard.swift @@ -0,0 +1,107 @@ +// MenuRebuildGuard.swift +// MCPProxy +// +// Rebuild policy for the status-bar menu while it is on screen. + +import AppKit + +/// What a rebuild request is allowed to do at this moment. +enum MenuRebuildDecision: Equatable { + /// The menu is closed — clear it and build every item from scratch. + case rebuild + /// The menu is open and the new glance rows line up 1:1 with the installed + /// ones — rewrite them in place, add and remove nothing. + case updateInPlace + /// The menu is open and the structure changed — do nothing now, remember + /// that a rebuild is owed, and run it when the menu closes. + case deferUntilClose +} + +/// Tracks whether the status-bar menu is on screen and whether a structural +/// rebuild was suppressed while it was. +/// +/// Live SSE rows make the debounced `objectWillChange -> rebuildMenu()` sink +/// fire during active traffic, i.e. potentially while the user is reading the +/// menu. `removeAllItems()` under the cursor — a menu that grows or shrinks +/// mid-read, or an open submenu that collapses — is exactly the irritation the +/// glance design forbids, so structural churn waits for `menuDidClose`. +/// +/// **The invariant that makes this safe: `menuWillOpen` rebuilds the menu +/// unconditionally before every single display.** So the guard governs only what +/// happens *between* an open and a close, and anything it suppresses is +/// re-applied before the user can next see the menu. Nothing this guard drops +/// can survive into a display — which is why suppressing non-glance sections +/// too (they are not consulted at all while open) is a freeze rather than a +/// staleness bug, and why the deferred rebuild on `menuDidClose` is a belt to +/// that brace rather than the only thing standing between the user and stale +/// rows. `updateStatusIcon()` deliberately sits outside the guard, so the tray +/// icon remains a live alert channel even while the menu is frozen. +struct MenuRebuildGuard { + /// True between `menuWillOpen()` and `menuDidClose()`. + private(set) var isMenuOpen = false + + /// True when a structural rebuild was suppressed while the menu was open. + private(set) var isDirty = false + + /// Arm the guard. Call AFTER the pre-display rebuild in `menuWillOpen`. + mutating func menuWillOpen() { + isMenuOpen = true + isDirty = false + } + + /// Decide what a rebuild request may do. + /// - Parameter structureChanged: true when the new glance rows cannot be + /// written over the installed ones (different count or layout). + mutating func decide(structureChanged: Bool) -> MenuRebuildDecision { + guard isMenuOpen else { return .rebuild } + if structureChanged { + isDirty = true + return .deferUntilClose + } + return .updateInPlace + } + + /// Disarm the guard. Returns true when a rebuild was deferred and is owed. + mutating func menuDidClose() -> Bool { + isMenuOpen = false + let owed = isDirty + isDirty = false + return owed + } +} + +extension MenuRebuildGuard { + + /// The whole open-menu policy in one call: while the menu is on screen ask + /// `section` to rewrite its rows in place, and treat its refusal — which it + /// reports having mutated nothing — as the structural change that must wait + /// for `menuDidClose`. + /// + /// A closed menu never reaches the section at all: it is about to be rebuilt + /// from scratch, so updating rows that are on their way to `removeAllItems` + /// would be wasted work, and would leave the section's row references + /// pointing at items no longer in the menu. + /// + /// This lives here, rather than inline in `rebuildMenu()`, so the policy can + /// be tested without an `NSStatusItem`. + /// + /// `@MainActor` because this call reaches into `GlanceSection`, which is + /// itself `@MainActor` — it mutates menu items already on screen. + /// `MenuRebuildGuard` stays un-isolated as a type: its other members are + /// pure state transitions with no AppKit in them, and isolating those would + /// buy nothing. + /// + /// Scope of that guarantee at swift-tools-version 5.9 (minimal concurrency + /// checking): a direct synchronous call from a nonisolated context is a hard + /// error, but the same call inside a `@Sendable` closure dispatched to + /// another queue is only a warning (an error under the Swift 6 language + /// mode). So this makes the common mistake unrepresentable and the async one + /// loud, rather than catching every shape of it. + @MainActor + mutating func decide(refreshing section: GlanceSection, + from state: AppState, + now: Date = Date()) -> MenuRebuildDecision { + guard isMenuOpen else { return .rebuild } + return decide(structureChanged: !section.updateInPlace(for: state, now: now)) + } +} diff --git a/native/macos/MCPProxy/MCPProxy/Menu/TrayMenu.swift b/native/macos/MCPProxy/MCPProxy/Menu/TrayMenu.swift deleted file mode 100644 index 8c90c4b9a..000000000 --- a/native/macos/MCPProxy/MCPProxy/Menu/TrayMenu.swift +++ /dev/null @@ -1,511 +0,0 @@ -// TrayMenu.swift -// MCPProxy -// -// The MenuBarExtra content view. Renders the full tray menu with server list, -// recent activity, quarantine alerts, and management actions. - -import SwiftUI - -struct TrayMenu: View { - @ObservedObject var appState: AppState - @ObservedObject var updateService: UpdateService - let onRestart: () -> Void - let onQuit: () -> Void - - @State private var apiClient: APIClient? - - var body: some View { - // MARK: - Header - headerSection - - Divider() - - // MARK: - Spec 044 Fix Issues (classified diagnostics) - if !appState.serversWithDiagnostic.isEmpty { - fixIssuesSection - Divider() - } - - // MARK: - Attention / Quarantine - if !appState.serversNeedingAttention.isEmpty { - attentionSection - Divider() - } - - if appState.quarantinedToolsCount > 0 { - quarantineSection - Divider() - } - - // MARK: - Servers - if !appState.servers.isEmpty { - serversSection - Divider() - } - - // MARK: - Recent Activity - if !appState.recentActivity.isEmpty { - activitySection - Divider() - } - - // MARK: - Sensitive Data - if appState.sensitiveDataAlertCount > 0 { - sensitiveDataSection - Divider() - } - - // MARK: - Actions - actionsSection - - Divider() - - // MARK: - Settings - settingsSection - - Divider() - - // MARK: - Quit - Button("Quit MCPProxy") { - onQuit() - } - .keyboardShortcut("q") - } - - // MARK: - Header Section - - @ViewBuilder - private var headerSection: some View { - if appState.version.isEmpty { - Text("MCPProxy") - .font(.headline) - } else { - Text("MCPProxy v\(appState.version)") - .font(.headline) - } - - Text(appState.statusSummary) - .font(.subheadline) - .foregroundStyle(.secondary) - - // Show error detail and retry button when in an error state - if case .error(let coreError) = appState.coreState { - Text(coreError.remediationHint) - .font(.caption2) - .foregroundStyle(.red) - - if coreError.isRetryable { - Button("Retry") { - onRestart() - } - } - } - } - - // MARK: - Fix Issues Section (Spec 044) - - /// Renders the "Fix issues" group, one entry per server that has a - /// classified diagnostic with warn/error severity. Clicking opens the - /// server detail page in the web UI where ErrorPanel renders the - /// full fix_steps list. - @ViewBuilder - private var fixIssuesSection: some View { - let affected = appState.serversWithDiagnostic - Text("⚠ Fix issues (\(affected.count))") - .font(.caption) - .foregroundStyle(.orange) - - ForEach(affected) { server in - Button { - openWebUI(path: "servers/\(server.name)") - } label: { - HStack { - Image(systemName: severityIcon(for: server.diagnostic?.severity)) - .foregroundStyle(severityColor(for: server.diagnostic?.severity)) - VStack(alignment: .leading) { - Text(server.name) - Text(server.diagnostic?.code ?? "") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - } - } - } - - // MARK: - Attention Section - - @ViewBuilder - private var attentionSection: some View { - Text("Needs Attention") - .font(.caption) - .foregroundStyle(.secondary) - - ForEach(appState.serversNeedingAttention) { server in - Button { - handleServerAction(server) - } label: { - HStack { - Image(systemName: actionIcon(for: server.health?.action ?? "")) - VStack(alignment: .leading) { - Text(server.name) - Text(server.health?.summary ?? "") - .font(.caption) - .foregroundStyle(.secondary) - } - // MCP-1819/T3 — surface the "Sign in" verb explicitly for the - // OAuth login-required state so the actionable affordance is - // clear, not buried. Scoped to login (this issue's concern); - // other attention actions keep their prior icon-only row. - if server.isOAuthLoginRequired, let label = server.health?.healthAction?.label { - Spacer() - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - } - - // MARK: - Quarantine Section - - @ViewBuilder - private var quarantineSection: some View { - let quarantinedServers = appState.servers.filter { $0.quarantined } - Label( - "\(appState.quarantinedToolsCount) quarantined server\(appState.quarantinedToolsCount == 1 ? "" : "s")", - systemImage: "shield.lefthalf.filled" - ) - .foregroundStyle(.orange) - - ForEach(quarantinedServers) { server in - Button("Approve \(server.name)") { - Task { - try? await apiClient?.unquarantineServer(server.id) - } - } - } - } - - // MARK: - Servers Section - - @ViewBuilder - private var serversSection: some View { - Text("Servers") - .font(.caption) - .foregroundStyle(.secondary) - - ForEach(appState.servers) { server in - Menu { - serverSubmenu(for: server) - } label: { - HStack { - Circle() - .fill(serverStatusColor(for: server)) - .frame(width: 8, height: 8) - Text(server.name) - Spacer() - if server.toolCount > 0 { - Text("\(server.toolCount) tools") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - } - - /// Submenu for individual server actions. - @ViewBuilder - private func serverSubmenu(for server: ServerStatus) -> some View { - // Status info - let summary = server.health?.summary ?? (server.connected ? "Connected" : "Disconnected") - Text(summary) - .font(.caption) - - if let detail = server.health?.detail, !detail.isEmpty { - Text(detail) - .font(.caption2) - .foregroundStyle(.secondary) - } - - Divider() - - // Enable / Disable (stdio servers use Stop/Start terminology) - if server.enabled { - Button(server.protocol == "stdio" ? "Stop" : "Disable") { - Task { - try? await apiClient?.disableServer(server.id) - } - } - } else { - Button(server.protocol == "stdio" ? "Start" : "Enable") { - Task { - try? await apiClient?.enableServer(server.id) - } - } - } - - // Restart - Button("Restart") { - Task { - try? await apiClient?.restartServer(server.id) - } - } - - // OAuth Sign in (shown when login is required) — calm, actionable - // affordance, not error framing (MCP-1819/T3). - if server.isOAuthLoginRequired { - Button("Sign in") { - Task { - try? await apiClient?.loginServer(server.id) - } - } - } - - // View Logs - Button("View Logs") { - openLogsForServer(server.name) - } - } - - // MARK: - Activity Section - - @ViewBuilder - private var activitySection: some View { - Text("Recent Activity") - .font(.caption) - .foregroundStyle(.secondary) - - ForEach(appState.recentActivity.prefix(5)) { entry in - HStack { - Image(systemName: activityIcon(for: entry)) - VStack(alignment: .leading) { - Text(activitySummaryText(for: entry)) - .font(.caption) - .lineLimit(1) - Text(relativeTime(entry.timestamp)) - .font(.caption2) - .foregroundStyle(.secondary) - } - } - } - } - - // MARK: - Sensitive Data Section - - @ViewBuilder - private var sensitiveDataSection: some View { - Label( - "\(appState.sensitiveDataAlertCount) sensitive data detection\(appState.sensitiveDataAlertCount == 1 ? "" : "s")", - systemImage: "exclamationmark.triangle.fill" - ) - .foregroundStyle(.red) - - Button("View in Web UI") { - openWebUI(path: "activity?sensitive=true") - } - } - - // MARK: - Actions Section - - @ViewBuilder - private var actionsSection: some View { - Button("Open Web UI") { - openWebUI() - } - - Button("Open Config File") { - openConfigFile() - } - - Button("Open Logs Directory") { - openLogsDirectory() - } - } - - // MARK: - Settings Section - - @ViewBuilder - private var settingsSection: some View { - Toggle("Run at Startup", isOn: Binding( - get: { appState.autoStartEnabled }, - set: { newValue in - do { - if newValue { - try AutoStartService.enable() - } else { - try AutoStartService.disable() - } - appState.autoStartEnabled = newValue - } catch { - // Revert on failure; the toggle will snap back - appState.autoStartEnabled = !newValue - } - } - )) - - Button("Check for Updates") { - updateService.checkForUpdates() - } - .disabled(!updateService.canCheckForUpdates) - - if let available = appState.updateAvailable ?? updateService.latestVersion { - Text("Update available: v\(available)") - .font(.caption) - .foregroundStyle(.blue) - } - } - - // MARK: - Helpers - - private func serverStatusColor(for server: ServerStatus) -> Color { - if server.quarantined { - return .orange - } - if let health = server.health { - switch health.level { - case "healthy": - return .green - case "degraded": - return .yellow - case "unhealthy": - return .red - default: - return server.connected ? .green : .red - } - } - return server.connected ? .green : .red - } - - /// Spec 044 — map a diagnostic severity string to an SF Symbol name. - private func severityIcon(for severity: String?) -> String { - switch severity { - case "error": return "xmark.octagon.fill" - case "warn": return "exclamationmark.triangle.fill" - default: return "info.circle" - } - } - - /// Spec 044 — map a diagnostic severity string to a colour. - private func severityColor(for severity: String?) -> Color { - switch severity { - case "error": return .red - case "warn": return .orange - default: return .blue - } - } - - private func actionIcon(for action: String) -> String { - switch action { - case "login": - return "person.badge.key" - case "restart": - return "arrow.clockwise" - case "enable": - return "power" - case "approve": - return "checkmark.shield" - case "set_secret", "configure": - return "gearshape" - case "view_logs": - return "doc.text" - default: - return "exclamationmark.circle" - } - } - - private func activityIcon(for entry: ActivityEntry) -> String { - if entry.hasSensitiveData == true { - return "exclamationmark.triangle.fill" - } - switch entry.status { - case "error": - return "xmark.circle" - default: - return "checkmark.circle" - } - } - - /// Build a one-line summary for an activity entry. - private func activitySummaryText(for entry: ActivityEntry) -> String { - var parts: [String] = [] - if let serverName = entry.serverName, !serverName.isEmpty { - parts.append(serverName) - } - if let toolName = entry.toolName, !toolName.isEmpty { - parts.append(toolName) - } - if parts.isEmpty { - parts.append(entry.type) - } - return parts.joined(separator: ":") - } - - /// Parse the ISO 8601 timestamp string and format as relative time. - private func relativeTime(_ timestamp: String) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = formatter.date(from: timestamp) { - let relative = RelativeDateTimeFormatter() - relative.unitsStyle = .abbreviated - return relative.localizedString(for: date, relativeTo: Date()) - } - // Fallback: try without fractional seconds - formatter.formatOptions = [.withInternetDateTime] - if let date = formatter.date(from: timestamp) { - let relative = RelativeDateTimeFormatter() - relative.unitsStyle = .abbreviated - return relative.localizedString(for: date, relativeTo: Date()) - } - return timestamp - } - - private func handleServerAction(_ server: ServerStatus) { - guard let action = server.health?.action else { return } - switch action { - case "login": - Task { try? await apiClient?.loginServer(server.id) } - case "restart": - Task { try? await apiClient?.restartServer(server.id) } - case "enable": - Task { try? await apiClient?.enableServer(server.id) } - case "approve": - openWebUI(path: "servers/\(server.name)") - case "view_logs": - openLogsForServer(server.name) - default: - openWebUI(path: "servers/\(server.name)") - } - } - - private func openWebUI(path: String = "") { - let baseURLString = appState.webUIBaseURL - if let url = URL(string: "\(baseURLString)/ui/\(path)") { - NSWorkspace.shared.open(url) - } - } - - private func openConfigFile() { - let homeDir = FileManager.default.homeDirectoryForCurrentUser - let configPath = homeDir.appendingPathComponent(".mcpproxy/mcp_config.json") - NSWorkspace.shared.open(configPath) - } - - private func openLogsDirectory() { - let homeDir = FileManager.default.homeDirectoryForCurrentUser - let logsPath = homeDir.appendingPathComponent("Library/Logs/mcpproxy") - NSWorkspace.shared.open(logsPath) - } - - private func openLogsForServer(_ serverName: String) { - let homeDir = FileManager.default.homeDirectoryForCurrentUser - let logFile = homeDir.appendingPathComponent("Library/Logs/mcpproxy/\(serverName).log") - if FileManager.default.fileExists(atPath: logFile.path) { - NSWorkspace.shared.open(logFile) - } else { - openLogsDirectory() - } - } -} diff --git a/native/macos/MCPProxy/MCPProxy/Services/NotificationService.swift b/native/macos/MCPProxy/MCPProxy/Services/NotificationService.swift index 9d384eec3..9f7c2bf18 100644 --- a/native/macos/MCPProxy/MCPProxy/Services/NotificationService.swift +++ b/native/macos/MCPProxy/MCPProxy/Services/NotificationService.swift @@ -156,6 +156,16 @@ actor NotificationService { // MARK: - Notification Senders /// Notify about sensitive data detected in a tool call. + /// + /// NO PRODUCTION CALLERS as of the tray glance work, and none before it + /// either: the only caller was a `case "activity"` SSE branch, and no Go + /// code emits a bare `"activity"` event, so this has never fired on any + /// build. The intended trigger is the `sensitive_data.detected` event the + /// core does emit (`internal/runtime/events.go`), which the tray's SSE + /// switch has no case for. Kept rather than deleted because wiring that + /// event is a scoped feature — copy, rate-limiter keying, whether it should + /// also drive the tray icon — and this is the reviewed, rate-limited + /// plumbing it will want. `NotificationRateLimitTests` covers it meanwhile. func sendSensitiveDataAlert(server: String, tool: String, category: String) async { // Suppress while the connection is unsettled: a re-init loop replays // the full activity list each cycle, which the count-delta heuristic diff --git a/native/macos/MCPProxy/MCPProxy/State/AppState.swift b/native/macos/MCPProxy/MCPProxy/State/AppState.swift index 76fd1ec55..d37fd7aab 100644 --- a/native/macos/MCPProxy/MCPProxy/State/AppState.swift +++ b/native/macos/MCPProxy/MCPProxy/State/AppState.swift @@ -34,7 +34,40 @@ final class AppState: ObservableObject { // MARK: Core lifecycle /// Current core process state (uses CoreState from CoreState.swift). - @Published var coreState: CoreState = .idle + /// + /// Tray Glance: any state other than `.connected` clears the glance feeds. + /// The connect path flips to `.connected` BEFORE the first refresh completes + /// (CoreProcessManager.launchAndConnect), so without this reset the menu + /// would briefly present the previous core's numbers as live. The reset lives + /// in `didSet` rather than in `transition(to:)` because two call sites assign + /// `coreState` directly (CoreProcessManager.awaitExternalCore, + /// MCPProxyApp.stopCore) and would otherwise bypass it. + @Published var coreState: CoreState = .idle { + didSet { + if coreState != oldValue { connectionGeneration &+= 1 } + if coreState != .connected { clearGlanceState() } + } + } + + /// Which connection the tray is on. Bumped on every real `coreState` + /// change, so a value captured before a fetch identifies the core that + /// fetch was issued to. + /// + /// `coreState == .connected` alone cannot tell a response from THIS + /// connection from one issued to a core that has since died and been + /// replaced: reconnection restores `.connected`, and the guard then admits + /// the dead core's data. Comparing generations does tell them apart. + /// Re-assigning an unchanged state is deliberately not a new generation — + /// discarding a good in-flight response over a redundant publish would cost + /// liveness for nothing. + @Published private(set) var connectionGeneration: Int = 0 + + /// Whether `generation` still identifies the live connection. The predicate + /// every glance publish must satisfy: connected, and connected to the same + /// core the fetch was issued to. + func isCurrentConnection(_ generation: Int) -> Bool { + coreState == .connected && generation == connectionGeneration + } /// Who owns the core process. @Published var ownership: CoreOwnership = .trayManaged @@ -72,6 +105,61 @@ final class AppState: ObservableObject { /// Monotonic counter bumped on SSE servers.changed / config.reloaded for live updates. @Published var serversVersion: Int = 0 + // MARK: Tray Glance feeds (separate from the shared recentActivity/recentSessions) + + /// Tool-call activity for the tray glance section, fetched with a `type` + /// filter. Deliberately NOT the same feed as `recentActivity`: the native + /// Dashboard renders the full activity log (security scans, quarantine + /// changes, OAuth events) from that one, so narrowing it would gut the view. + @Published var glanceActivity: [ActivityEntry] = [] + + /// Active-only MCP sessions for the tray glance "Clients" rows. Separate from + /// `recentSessions`, which ActivityView and DashboardView use to resolve + /// session ids to client names and therefore must keep closed sessions. + @Published var glanceSessions: [APIClient.MCPSession] = [] + + /// Hourly call timeline for the last 24h. `nil` means "not loaded yet"; + /// an empty array means "loaded, and the proxy was idle". + @Published var usageTimeline: [UsageBucket]? + + /// Calls recorded in the CURRENT UTC hour. `nil` means "not loaded yet". + @Published var callsThisHour: Int? + + /// Last usage-refresh failure, surfaced as a muted row in the histogram + /// submenu. `nil` means "no failure recorded"; the next successful refresh + /// clears it. Without this the submenu could not tell "still loading" from + /// "the fetch failed" — both leave `usageTimeline` nil, and a permanently + /// failing refresh would sit on "Loading…" forever. + @Published var usageError: String? + + /// Which glance feed a recorded failure came from. + enum GlanceFeed: String, CaseIterable { + case activity, sessions, usage + } + + /// Consecutive failed refreshes per feed; a success resets that feed's + /// entry. Per-feed rather than one shared counter because the three fetch + /// independently — a single counter that any success reset would report a + /// permanently failing feed as healthy every 30 seconds. + private(set) var glanceFailureStreak: [GlanceFeed: Int] = [:] + + /// The most recent glance refresh failure, whichever feed produced it. + private(set) var glanceError: String? + + /// Consecutive failures of one feed before the block admits it is not + /// updating. At the 30-second poll cadence this is a minute and a half of + /// silence — long enough that a core restart passes unremarked, short + /// enough that a dead core is not presented as live for long. + static let glanceStaleFailureThreshold = 3 + + /// Whether any feed has been failing long enough to say so on screen. + /// + /// Published (and only flipped, never rewritten) because it changes what + /// the menu renders: every write here feeds the debounced + /// `objectWillChange → rebuildMenu()` sink, and a core that has been gone + /// for an hour must not rebuild the menu twice a minute forever. + @Published private(set) var glanceStale: Bool = false + // MARK: Token metrics (from status response) @Published var tokenMetrics: TokenMetrics? @@ -257,4 +345,387 @@ final class AppState: ObservableObject { func transition(to newState: CoreState) { coreState = newState } + + // MARK: Tray Glance helpers + + /// Truncate a date to the start of its UTC hour. Unix time is UTC by + /// definition, so flooring the epoch seconds needs no Calendar or TimeZone. + static func floorToHour(_ date: Date) -> Date { + let seconds = date.timeIntervalSince1970 + return Date(timeIntervalSince1970: (seconds / 3600).rounded(.down) * 3600) + } + + /// Calls recorded in the UTC hour containing `now`. + /// + /// Buckets are UTC-hour aligned and SPARSE — the endpoint omits hours with no + /// activity. Picking "the newest bucket" would therefore show a count from + /// hours ago as if it were current, so this matches on the bucket start and + /// returns 0 when the current hour has no bucket. + static func callsInCurrentHour(_ timeline: [UsageBucket], now: Date = Date()) -> Int { + let currentHour = floorToHour(now) + for bucket in timeline where floorToHour(bucket.start) == currentHour { + return bucket.calls + } + return 0 + } + + /// Store the 24h timeline and derive the current-hour headline count. + /// + /// Ignored unless the core is `.connected`, like the other two glance + /// updaters — see `updateGlanceActivity` for why. + /// + /// Both assignments are guarded. This file's rule (see `updateServers`) is + /// "only publish when the data actually differs", because every `@Published` + /// write feeds the debounced `objectWillChange → rebuildMenu()` sink in + /// MCPProxyApp — an unguarded write here would rebuild the menu every 30s on + /// a completely idle proxy. `UsageBucket` is `Equatable`, so the guard is free. + @MainActor + func updateUsage(timeline: [UsageBucket], now: Date = Date()) { + guard coreState == .connected else { return } + if usageTimeline != timeline { usageTimeline = timeline } + let calls = AppState.callsInCurrentHour(timeline, now: now) + if callsThisHour != calls { callsThisHour = calls } + if usageError != nil { usageError = nil } + clearGlanceFailure(.usage) + } + + /// Record a failed usage refresh so the histogram submenu can say so + /// instead of showing "Loading…" forever. Called from the usage refresh's + /// catch block. + /// + /// Guarded on `.connected` for the same reason `updateUsage` and + /// `updateGlanceActivity` are: a fetch already past its `guard let + /// apiClient` when the core goes away resolves after `clearGlanceState()`, + /// and its catch block would write the dead core's failure back over the + /// state that was just emptied. The submenu would then say "Usage + /// unavailable" about a core that is merely still starting. + @MainActor + func recordUsageFailure(_ message: String) { + guard coreState == .connected else { return } + if usageError != message { usageError = message } + recordGlanceFailure(.usage, message) + } + + /// Fetch the 24h usage aggregate and publish the outcome — success or + /// failure. + /// + /// The catch is the whole reason `usageError` exists. A failure that only + /// logged would leave the header count and the histogram submenu sitting on + /// "Loading…" indefinitely, describing a fetch that is never coming back as + /// one still in flight. + /// + /// Takes a `GlanceDataSource` rather than the concrete client so the + /// failure path is reachable from a test; untested wiring is exactly how + /// this state came to be unreachable in the first place. + @MainActor + func refreshUsage(from source: GlanceDataSource) async { + let generation = connectionGeneration + do { + let usage = try await source.usageAggregate(window: "24h", top: 1) + guard isCurrentConnection(generation) else { return } + updateUsage(timeline: usage.timeline) + } catch { + guard isCurrentConnection(generation) else { return } + recordUsageFailure(AppState.usageFailureMessage(for: error)) + } + } + + /// The text shown in the failed row's tooltip. `APIClientError` is a + /// `LocalizedError`, so this is already "HTTP 503: …" or "Core is not + /// ready" rather than a Swift type dump. + static func usageFailureMessage(for error: Error) -> String { + let text = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? "Usage refresh failed" : text + } + + /// Fetch the glance activity feed and publish the outcome — success or + /// failure. + /// + /// Takes a `GlanceDataSource` rather than the concrete client for the same + /// reason `refreshUsage(from:)` does: this used to live in + /// `CoreProcessManager` where the catch block was unreachable from a test, + /// and an unreachable failure path is how the feed came to swallow errors. + /// A permanently failing fetch rendered "No tool calls yet" — the same thing + /// a genuinely idle proxy renders. + @MainActor + func refreshGlanceActivity(from source: GlanceDataSource) async { + let generation = connectionGeneration + do { + let entries = try await source.glanceActivity(limit: AppState.glanceActivityPageSize) + guard isCurrentConnection(generation) else { return } + updateGlanceActivity(entries) + clearGlanceFailure(.activity) + } catch { + guard isCurrentConnection(generation) else { return } + recordGlanceFailure(.activity, AppState.usageFailureMessage(for: error)) + } + } + + /// Fetch the active-only session feed and publish the outcome — success or + /// failure. See `refreshGlanceActivity(from:)`; a permanently failing fetch + /// here rendered "No connected clients". + @MainActor + func refreshGlanceSessions(from source: GlanceDataSource) async { + let generation = connectionGeneration + do { + let sessions = try await source.activeSessions(limit: AppState.glanceSessionsPageSize) + guard isCurrentConnection(generation) else { return } + updateGlanceSessions(sessions) + clearGlanceFailure(.sessions) + } catch { + guard isCurrentConnection(generation) else { return } + recordGlanceFailure(.sessions, AppState.usageFailureMessage(for: error)) + } + } + + /// Record a failed refresh of one glance feed. + /// + /// Guarded on `.connected` for the same reason every publish helper here is + /// (see `updateGlanceActivity`): a fetch already in flight when the core + /// goes away resolves into its catch block after `clearGlanceState()`, and + /// the dead core's failure must not outlive it. + @MainActor + func recordGlanceFailure(_ feed: GlanceFeed, _ message: String) { + guard coreState == .connected else { return } + glanceFailureStreak[feed, default: 0] += 1 + if glanceError != message { glanceError = message } + refreshStaleMarker() + } + + /// Clear one feed's failure record after a successful refresh. Only that + /// feed's — the others may still be failing. + @MainActor + func clearGlanceFailure(_ feed: GlanceFeed) { + guard coreState == .connected else { return } + glanceFailureStreak[feed] = 0 + if glanceFailureStreak.values.allSatisfy({ $0 == 0 }), glanceError != nil { + glanceError = nil + } + refreshStaleMarker() + } + + /// Recompute `glanceStale` from the streaks. + @MainActor + private func refreshStaleMarker() { + let stale = glanceFailureStreak.values.contains { $0 >= AppState.glanceStaleFailureThreshold } + if glanceStale != stale { glanceStale = stale } + } + + /// Records requested per glance activity poll — the server's maximum. + /// + /// Rules 1-3 run client-side, AFTER the page arrives, so the five rows are + /// only ever as deep as one page: a burst of `upstream_servers` / + /// `quarantine_security` calls at the head of the log pushes real calls off + /// it and the menu says "No tool calls yet" while they sit below the fold. + /// At 50 that took 46 management calls; a real agent walking a server list + /// makes them in bursts. + /// + /// 100 is not a round number, it is the ceiling: `ActivityFilter.Validate` + /// (internal/storage/activity_models.go) clamps `limit` to 100, so a larger + /// request is silently served as 100. + /// + /// Deeper than that would need paging, and paging is the wrong trade here + /// even though `offset` does work end-to-end on this endpoint. + /// `Manager.ListActivities` walks and unmarshals the ENTIRE activity bucket + /// on every call — it never breaks early, because it counts `total` — and + /// that bucket holds up to `activity_max_records` (100,000) records. So the + /// cost of a request is set by the bucket, not by `limit`: doubling the page + /// costs 50 more struct copies, while a second page costs a whole extra + /// 100k-record walk every 30 seconds. One deeper page buys most of the + /// headroom for a fraction of the cost. + /// + /// The residual is real and deliberately not papered over. Stated exactly: + /// five rows are guaranteed only when five distinct REQUEST GROUPS survive + /// all four selection rules within the newest 100 records matching the type + /// filter. Groups, not records, because rule 4 collapses every record + /// sharing a `request_id` into one row — a failed call contributes a wrapper + /// and an upstream record, so five qualifying records can be as few as three + /// rows. `testRowsGoNoDeeperThanOnePage` pins the depth boundary and + /// `testFiveQualifyingRecordsSharingRequestIDsProduceFewerRows` the group one. + static let glanceActivityPageSize = 100 + + /// Active sessions requested per poll. + static let glanceSessionsPageSize = 25 + + /// Replace the glance activity feed. Leaves `recentActivity` untouched. + /// + /// Ignored unless the core is `.connected`. `clearGlanceState()` alone is not + /// enough: a fetch already past its `guard let apiClient` when the core goes + /// away resolves after the reset and would write the dead core's data back + /// over the cleared fields. `CoreProcessManager.shutdown()` transitions to + /// `.shuttingDown` before it cancels `refreshTask`, and cancellation would not + /// close the window anyway — a suspended fetch still resumes and still runs + /// the update that follows it. + /// + /// `ActivityEntry`'s Equatable is id-only (API/Models.swift:570), so guarding + /// on ids alone would drop the reconciling poll's late corrections: the + /// sensitive-data flag is computed asynchronously and the final status + /// arrives on a record whose id has not changed. Fingerprint the fields the + /// glance rows actually render instead — still cheap, still churn-free. + @MainActor + func updateGlanceActivity(_ entries: [ActivityEntry]) { + guard coreState == .connected else { return } + let merged = AppState.mergeGlanceActivity(polled: entries, + into: glanceActivity, + unconfirmed: unconfirmedLiveKeys) + unconfirmedLiveKeys = merged.unconfirmed + func fingerprint(_ list: [ActivityEntry]) -> [String] { + list.map { "\($0.id)|\($0.status)|\($0.hasSensitiveData == true)" } + } + if fingerprint(merged.rows) != fingerprint(glanceActivity) { + glanceActivity = merged.rows + } + } + + /// Record keys of SSE rows no poll has confirmed yet — the only rows the + /// merge may keep against a page that omits them. + /// + /// Not `@Published`: it changes on every poll and nothing renders it. + private(set) var unconfirmedLiveKeys: Set = [] + + /// Reconcile a polled page with the rows already on screen. + /// + /// MERGE, not replace, and the choice is load-bearing. `AppState` is + /// reached through `await`, so `prependGlanceActivity` can insert an SSE row + /// at index 0 while the GET that produced `polled` is still suspended. A + /// wholesale replace then erases a row the response could not have known + /// about, and the call is missing from the menu until the next poll — the + /// user watches a call appear and vanish for up to 30 seconds. + /// + /// Keyed on `GlanceSelection.recordKey` (the `requestId`), which is already + /// the identity function for rule 4's collapse and for the row diff: the + /// storage id and the SSE row's provisional `:` differ for + /// one and the same call, so id-keying would keep both. + /// + /// Only an UNCONFIRMED row can be retained — one this tray prepended from an + /// SSE event that no poll has carried back yet. That is the whole population + /// the race can strand, and confining retention to it is what keeps the + /// merge from resurrecting the dead: a canonical row the server has dropped + /// (retention, an explicit delete) is absent from the page and may well be + /// newer than everything left in it, so an "absent and newer" rule alone + /// would keep showing it — permanently, on a core idle enough that no later + /// page ever reaches back past it, while the successful polls keep clearing + /// the staleness marker that would otherwise hint something is wrong. + /// + /// An unconfirmed row is retained when it is BOTH absent from the page and + /// newer than everything the page carries; a page that has reached past it + /// has answered for it. Two pages carry no "newest record", and they mean + /// opposite things: + /// + /// * An EMPTY page contradicts nothing. It says the server had recorded no + /// matching calls when it answered, which is silence about a call it had + /// not written yet — so unconfirmed rows stand. + /// * A page WITH records but no parsable timestamps is still the server's + /// own account of the feed, and "newer than" is unanswerable against it, + /// so the page wins. + /// + /// Returns the merged rows and the keys still unconfirmed: anything the page + /// carried is the server's record now, and a row that was dropped is nobody's. + static func mergeGlanceActivity( + polled: [ActivityEntry], + into existing: [ActivityEntry], + unconfirmed: Set + ) -> (rows: [ActivityEntry], unconfirmed: Set) { + // Deliberately max(), not `polled.first`: the merge does not depend on + // the page arriving newest-first. + let newestPolled = polled.compactMap { GlanceFormatting.parseTimestamp($0.timestamp) }.max() + let polledKeys = Set(polled.map(GlanceSelection.recordKey)) + + let retained: [ActivityEntry] + if polled.isEmpty { + retained = existing.filter { unconfirmed.contains(GlanceSelection.recordKey(for: $0)) } + } else if let newestPolled { + retained = existing.filter { entry in + let key = GlanceSelection.recordKey(for: entry) + guard unconfirmed.contains(key), !polledKeys.contains(key) else { return false } + guard let stamp = GlanceFormatting.parseTimestamp(entry.timestamp) else { return false } + return stamp > newestPolled + } + } else { + retained = [] + } + + let rows = Array((retained + polled).prefix(glanceActivityCap)) + let survived = Set(rows.map(GlanceSelection.recordKey)) + return (rows, Set(retained.map(GlanceSelection.recordKey)).intersection(survived)) + } + + /// Upper bound on rows kept in `glanceActivity`. Deliberately equal to + /// `glanceActivityPageSize`, so SSE rows and polled rows agree on depth. + static let glanceActivityCap = glanceActivityPageSize + + /// Prepend one optimistic row adapted from an SSE payload (newest first). + /// Bounded so a busy agent cannot grow the feed without limit; the 30s + /// reconciling poll replaces the list wholesale with canonical records. + /// + /// Guarded on the connection GENERATION, not merely on `.connected`, for the + /// same reason the three fetches are: SSE teardown is asynchronous, the + /// publish happens a MainActor hop after the event is read, and executors + /// promise no ordering between that hop and the reconnect work. An event + /// from the previous core can therefore resume after `.connected` has been + /// restored — `.connected` alone would wave it through and prepend a dead + /// core's call to the new core's feed. The caller captures `generation` + /// where the stream is opened, since a stream belongs to one connection. + /// + /// Deliberately without the equality guard `updateGlanceSessions(_:)` + /// carries — a prepend is by definition a change, and that guard exists only + /// to stop redundant `@Published` churn on identical poll results. (That + /// guard compares whole `MCPSession` values, not ids: the Clients rows + /// render a live per-session call count, which an id-only comparison froze.) + @MainActor + func prependGlanceActivity(_ entry: ActivityEntry, generation: Int) { + guard isCurrentConnection(generation) else { return } + // Unconfirmed until a poll carries it back: this is the only row the + // merge is allowed to keep against a page that omits it. + unconfirmedLiveKeys.insert(GlanceSelection.recordKey(for: entry)) + glanceActivity.insert(entry, at: 0) + if glanceActivity.count > AppState.glanceActivityCap { + glanceActivity.removeLast(glanceActivity.count - AppState.glanceActivityCap) + // The cap and the key set must not be able to disagree: a key whose + // row has just been capped away describes nothing, and the poll is + // the only other thing that prunes the set — so while polls fail and + // SSE keeps arriving, which is exactly when the staleness marker is + // showing, the set would otherwise grow for as long as the core is up. + unconfirmedLiveKeys.formIntersection(glanceActivity.map(GlanceSelection.recordKey)) + } + } + + /// Replace the glance (active-only) session feed. Leaves `recentSessions` untouched. + /// + /// Ignored unless the core is `.connected` — see `updateGlanceActivity`. + /// + /// Guarded on the whole value, not on ids: the tray's Clients rows render a + /// live per-session call count and last-activity age, so a session whose + /// `toolCallCount` moved between polls must republish. An id-only guard + /// froze both numbers at the first poll's values for as long as the session + /// list's membership held. `MCPSession` is `Equatable` for exactly this. + @MainActor + func updateGlanceSessions(_ sessions: [APIClient.MCPSession]) { + guard coreState == .connected else { return } + if sessions != glanceSessions { + glanceSessions = sessions + } + } + + /// Drop every glance feed. Called from `coreState.didSet` on any state other + /// than `.connected` so a stopped or reconnecting core never shows the + /// previous core's numbers as live. + /// + /// Deliberately NOT `@MainActor`: a property observer is a nonisolated + /// context, so an isolated method could not be called from it without + /// `await`. `AppState` itself is not `@MainActor` either, so a plain method + /// is already nonisolated. Every real assignment to `coreState` happens on + /// the main actor anyway — `transition(to:)`, plus the two `MainActor.run` + /// blocks in CoreProcessManager.awaitExternalCore and MCPProxyApp.stopCore. + func clearGlanceState() { + if !glanceActivity.isEmpty { glanceActivity = [] } + if !glanceSessions.isEmpty { glanceSessions = [] } + if usageTimeline != nil { usageTimeline = nil } + if callsThisHour != nil { callsThisHour = nil } + if usageError != nil { usageError = nil } + if !unconfirmedLiveKeys.isEmpty { unconfirmedLiveKeys = [] } + if !glanceFailureStreak.isEmpty { glanceFailureStreak = [:] } + if glanceError != nil { glanceError = nil } + if glanceStale { glanceStale = false } + } } diff --git a/native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift b/native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift index aeae963ff..943ea20af 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift @@ -478,8 +478,8 @@ struct DashboardView: View { } else if newCalls > existingCalls { byClient[key] = s } else if newCalls == existingCalls { - let existingTime = existing.lastActive ?? existing.startTime ?? "" - let newTime = s.lastActive ?? s.startTime ?? "" + let existingTime = existing.lastActivity ?? existing.startTime ?? "" + let newTime = s.lastActivity ?? s.startTime ?? "" if newTime > existingTime { byClient[key] = s } } } else { @@ -491,8 +491,8 @@ struct DashboardView: View { // and call counts match. `.values` iteration is random, so an // explicit sort key is required to keep the UI stable. return byClient.values.sorted { lhs, rhs in - let lTime = lhs.lastActive ?? lhs.startTime ?? "" - let rTime = rhs.lastActive ?? rhs.startTime ?? "" + let lTime = lhs.lastActivity ?? lhs.startTime ?? "" + let rTime = rhs.lastActivity ?? rhs.startTime ?? "" if lTime != rTime { return lTime > rTime } let lCalls = lhs.toolCallCount ?? 0 let rCalls = rhs.toolCallCount ?? 0 @@ -550,7 +550,7 @@ struct DashboardView: View { .font(.scaledMonospacedDigit(.caption, scale: fontScale)) .frame(width: 80, alignment: .trailing) - Text(sessionRelativeTime(session.lastActive ?? session.startTime)) + Text(sessionRelativeTime(session.lastActivity ?? session.startTime)) .font(.scaled(.caption, scale: fontScale)) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .trailing) diff --git a/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift new file mode 100644 index 000000000..cdacd7aa0 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/APIClientGlanceTests.swift @@ -0,0 +1,333 @@ +import XCTest +@testable import MCPProxy + +/// Request-shape and decoding tests for the three tray-glance API calls. +final class APIClientGlanceTests: XCTestCase { + + override func setUp() { + super.setUp() + GlanceStubURLProtocol.reset() + } + + override func tearDown() { + GlanceStubURLProtocol.reset() + super.tearDown() + } + + // MARK: - Usage aggregate + + func testUsageAggregateRequestsWindowAndTop() async throws { + GlanceStubURLProtocol.responseBody = GlanceStubURLProtocol.envelope(""" + {"window":"24h","token_source":"bytes","tokens_saved":184320, + "tokens_saved_percentage":92.4,"tools":[],"timeline":[]} + """) + let client = GlanceStubURLProtocol.makeClient() + + _ = try await client.usageAggregate() + + XCTAssertEqual( + GlanceStubURLProtocol.requestedURLs, + ["http://127.0.0.1:8080/api/v1/activity/usage?window=24h&top=1"] + ) + } + + func testUsageAggregateDecodesTimelineBuckets() async throws { + GlanceStubURLProtocol.responseBody = GlanceStubURLProtocol.envelope(""" + {"window":"24h","token_source":"bytes","tokens_saved":0, + "tokens_saved_percentage":0,"tools":[],"timeline":[ + {"start":"2026-07-29T13:00:00Z","calls":12,"errors":2,"total_resp_bytes":4096} + ]} + """) + let client = GlanceStubURLProtocol.makeClient() + + let usage = try await client.usageAggregate() + + XCTAssertEqual(usage.window, "24h") + XCTAssertEqual(usage.tokensSaved, 0) + XCTAssertEqual(usage.timeline.count, 1) + let bucket = try XCTUnwrap(usage.timeline.first) + XCTAssertEqual(bucket.calls, 12) + XCTAssertEqual(bucket.errors, 2) + XCTAssertEqual(bucket.totalRespBytes, 4096) + // 2026-07-29T13:00:00Z as seconds since the epoch. + XCTAssertEqual(bucket.start.timeIntervalSince1970, 1785330000, accuracy: 0.5) + } + + func testUsageBucketDecodesFractionalSecondTimestamps() throws { + let json = Data(""" + {"start":"2026-07-29T13:00:00.123Z","calls":1,"errors":0,"total_resp_bytes":0} + """.utf8) + + let bucket = try JSONDecoder().decode(UsageBucket.self, from: json) + + // Tolerance must stay well under the .123s being asserted, or the test + // passes even when the fractional-seconds branch is dropped. + XCTAssertEqual(bucket.start.timeIntervalSince1970, 1785330000.123, accuracy: 0.001) + } + + // MARK: - Glance activity + + /// The page is the endpoint's maximum, spelled out here rather than + /// interpolated: `AppState.glanceActivityPageSize` is only meaningful + /// because the server clamps `limit` to 100, and a test that reads the + /// constant back would follow it silently wherever it went. + func testGlanceActivityRequestsToolCallTypesAtTheServersMaximumPage() async throws { + GlanceStubURLProtocol.responseBody = GlanceStubURLProtocol.envelope(""" + {"activities":[],"total":0,"limit":100,"offset":0} + """) + let client = GlanceStubURLProtocol.makeClient() + + _ = try await client.glanceActivity() + + XCTAssertEqual( + GlanceStubURLProtocol.requestedURLs, + ["http://127.0.0.1:8080/api/v1/activity?type=tool_call,internal_tool_call" + + "&limit=100&exclude_payloads=true"] + ) + } + + /// The projection is what makes the 100-record page affordable: a full + /// record carries arguments, response and metadata, which the glance never + /// renders and which measure ~848KB per 100 records against a real log, + /// versus ~30KB projected. The Dashboard's feed must NOT ask for it — it + /// renders exactly those fields. + func testOnlyTheGlanceFeedAsksForTheProjection() async throws { + GlanceStubURLProtocol.responseBody = GlanceStubURLProtocol.envelope(""" + {"activities":[],"total":0,"limit":10,"offset":0} + """) + let client = GlanceStubURLProtocol.makeClient() + + _ = try await client.recentActivity(limit: 10) + + XCTAssertEqual( + GlanceStubURLProtocol.requestedURLs, + ["http://127.0.0.1:8080/api/v1/activity?limit=10"] + ) + } + + func testGlanceActivityDecodesEntries() async throws { + GlanceStubURLProtocol.responseBody = GlanceStubURLProtocol.envelope(""" + {"activities":[ + {"id":"01J","type":"tool_call","status":"error","timestamp":"2026-07-29T13:04:05Z", + "server_name":"jira","tool_name":"get_issue","error_message":"auth failed", + "request_id":"req-1"} + ],"total":1,"limit":50,"offset":0} + """) + let client = GlanceStubURLProtocol.makeClient() + + let entries = try await client.glanceActivity() + + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries.first?.serverName, "jira") + XCTAssertEqual(entries.first?.toolName, "get_issue") + XCTAssertEqual(entries.first?.errorMessage, "auth failed") + // The two fields the whole feature branches on, and the two this test + // used not to assert: `type` drives rules 1-3 and rowLabel's + // `server:tool` decision, `status` drives the symbol, the tint, the + // VoiceOver phrasing and the error clause. Decoded wrong, every one of + // them is wrong, and no other test sees the wire format. + XCTAssertEqual(entries.first?.type, "tool_call") + XCTAssertEqual(entries.first?.status, "error") + } + + // MARK: - Active sessions + + func testActiveSessionsRequestsStatusActive() async throws { + GlanceStubURLProtocol.responseBody = GlanceStubURLProtocol.envelope(""" + {"sessions":[],"total":0,"limit":25} + """) + let client = GlanceStubURLProtocol.makeClient() + + _ = try await client.activeSessions() + + XCTAssertEqual( + GlanceStubURLProtocol.requestedURLs, + ["http://127.0.0.1:8080/api/v1/sessions?status=active&limit=25"] + ) + } + + /// Regression: the model decoded `last_active`, but the API emits + /// `last_activity`, so every session's timestamp silently arrived as nil. + func testMCPSessionDecodesLastActivity() throws { + let json = Data(""" + {"id":"sess-1","client_name":"Claude Code","status":"active", + "tool_call_count":8,"start_time":"2026-07-29T12:00:00Z", + "last_activity":"2026-07-29T13:04:05Z"} + """.utf8) + + let session = try JSONDecoder().decode(APIClient.MCPSession.self, from: json) + + XCTAssertEqual(session.lastActivity, "2026-07-29T13:04:05Z") + XCTAssertEqual(session.clientName, "Claude Code") + XCTAssertEqual(session.toolCallCount, 8) + } + + // MARK: - Non-2xx error path + + /// Unwrap an APIClientError.httpError, failing the test on any other outcome. + private func expectHTTPError( + _ body: @autoclosure () async throws -> Any, + file: StaticString = #filePath, + line: UInt = #line + ) async -> (statusCode: Int, message: String)? { + do { + _ = try await body() + XCTFail("expected the call to throw, but it returned normally", file: file, line: line) + return nil + } catch let error as APIClientError { + guard case .httpError(let statusCode, let message) = error else { + XCTFail("expected .httpError, got \(error)", file: file, line: line) + return nil + } + return (statusCode, message) + } catch { + XCTFail("expected APIClientError, got \(error)", file: file, line: line) + return nil + } + } + + /// The live core returns 400 for `?status=bogus`. The failure must surface as + /// an httpError keyed off the status code — note the error body also carries + /// `request_id`, which must not disturb the message extraction. + func testActiveSessionsSurfaces400AsHTTPError() async throws { + GlanceStubURLProtocol.statusCode = 400 + GlanceStubURLProtocol.responseBody = Data(""" + {"success":false,"error":"invalid status filter: bogus","request_id":"req-7"} + """.utf8) + let client = GlanceStubURLProtocol.makeClient() + + let failure = await expectHTTPError(try await client.activeSessions()) + + XCTAssertEqual(failure?.statusCode, 400) + XCTAssertEqual(failure?.message, "invalid status filter: bogus") + } + + func testUsageAggregateSurfaces500AsHTTPError() async throws { + GlanceStubURLProtocol.statusCode = 500 + GlanceStubURLProtocol.responseBody = Data(""" + {"success":false,"error":"usage snapshot unavailable","request_id":"req-8"} + """.utf8) + let client = GlanceStubURLProtocol.makeClient() + + let failure = await expectHTTPError(try await client.usageAggregate()) + + XCTAssertEqual(failure?.statusCode, 500) + XCTAssertEqual(failure?.message, "usage snapshot unavailable") + } + + /// A non-2xx with a body that is not the JSON error envelope must still fail + /// as an httpError, not as a decoding error. + func testGlanceActivitySurfacesNonJSONErrorBodyAsHTTPError() async throws { + GlanceStubURLProtocol.statusCode = 503 + GlanceStubURLProtocol.responseBody = Data("upstream unavailable".utf8) + let client = GlanceStubURLProtocol.makeClient() + + let failure = await expectHTTPError(try await client.glanceActivity()) + + XCTAssertEqual(failure?.statusCode, 503) + XCTAssertEqual(failure?.message, HTTPURLResponse.localizedString(forStatusCode: 503)) + } + + // MARK: - Decoding-error fidelity + + /// Unwrap an APIClientError.decodingError, failing the test on any other outcome. + private func expectDecodingError( + _ body: @autoclosure () async throws -> Any, + file: StaticString = #filePath, + line: UInt = #line + ) async -> String? { + do { + _ = try await body() + XCTFail("expected the call to throw, but it returned normally", file: file, line: line) + return nil + } catch let error as APIClientError { + guard case .decodingError(let underlying) = error else { + XCTFail("expected .decodingError, got \(error)", file: file, line: line) + return nil + } + return "\(underlying)" + } catch { + XCTFail("expected APIClientError, got \(error)", file: file, line: line) + return nil + } + } + + /// A malformed field inside an enveloped `data` must surface the error that + /// describes it. fetchWrapped's bare-decode fallback re-fails on the top-level + /// shape, and reporting *that* second error hides the real cause entirely. + func testEnvelopedBodyPreservesTheInnerDecodingError() async throws { + GlanceStubURLProtocol.responseBody = GlanceStubURLProtocol.envelope(""" + {"window":"24h","token_source":"bytes","tokens_saved":0, + "tokens_saved_percentage":0,"tools":[],"timeline":[ + {"start":"29/07/2026 13:00","calls":1,"errors":0,"total_resp_bytes":0} + ]} + """) + let client = GlanceStubURLProtocol.makeClient() + + let description = await expectDecodingError(try await client.usageAggregate()) + + XCTAssertEqual( + description?.contains("Not an RFC 3339 timestamp: 29/07/2026 13:00"), true, + "the timestamp error was masked; got: \(description ?? "nil")" + ) + } + + /// No-regression guard for the other side of the fallback: when the body is + /// genuinely NOT enveloped, the bare decode's error is the informative one and + /// must still be what surfaces. + func testUnwrappedBodyPreservesTheBareDecodingError() async throws { + GlanceStubURLProtocol.responseBody = Data(""" + {"activities":"not-an-array","total":0,"limit":50,"offset":0} + """.utf8) + let client = GlanceStubURLProtocol.makeClient() + + let description = await expectDecodingError(try await client.glanceActivity()) + + XCTAssertEqual( + description?.contains("activities"), true, + "the bare-decode error was masked; got: \(description ?? "nil")" + ) + } + + /// The fallback itself must keep working: an unwrapped body that decodes + /// cleanly is still accepted. + func testUnwrappedBodyStillDecodesSuccessfully() async throws { + GlanceStubURLProtocol.responseBody = Data(""" + {"activities":[ + {"id":"01K","type":"tool_call","status":"ok","timestamp":"2026-07-29T13:04:05Z", + "server_name":"gh","tool_name":"list_prs"} + ],"total":1,"limit":50,"offset":0} + """.utf8) + let client = GlanceStubURLProtocol.makeClient() + + let entries = try await client.glanceActivity() + + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries.first?.serverName, "gh") + } + + // MARK: - Data-source seam + + func testAPIClientConformsToGlanceDataSource() async throws { + GlanceStubURLProtocol.responseBody = GlanceStubURLProtocol.envelope(""" + {"sessions":[],"total":0,"limit":25} + """) + let source: any GlanceDataSource = GlanceStubURLProtocol.makeClient() + + _ = try await source.activeSessions(limit: 25) + + XCTAssertEqual(GlanceStubURLProtocol.requestedURLs.count, 1) + } + + func testCountingStubSatisfiesTheProtocolAndIssuesNoRequests() async throws { + let stub = CountingGlanceDataSource() + let source: any GlanceDataSource = stub + + _ = try await source.usageAggregate(window: "24h", top: 1) + _ = try await source.glanceActivity(limit: 50) + _ = try await source.activeSessions(limit: 25) + + XCTAssertEqual(stub.totalCallCount, 3) + XCTAssertTrue(GlanceStubURLProtocol.requestedURLs.isEmpty) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift new file mode 100644 index 000000000..dc5f4d760 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/ActivityHistogramTests.swift @@ -0,0 +1,765 @@ +import XCTest +import AppKit +import SwiftUI +@testable import MCPProxy + +/// Shared anchors. All times are UTC: the backend aligns buckets with +/// `rec.Timestamp.UTC().Truncate(time.Hour)`, so the tests use the same grid. +/// A top-level `private` declaration is visible to the whole file, so every +/// test class below shares these. +private enum Fixture { + /// 2027-01-15 08:35:00 UTC — deliberately mid-hour, so flooring is exercised. + static let now = Date(timeIntervalSince1970: 1_800_002_100) + /// 2027-01-15 08:00:00 UTC — the hour containing `now`; the newest bar. + static let currentHour = Date(timeIntervalSince1970: 1_800_000_000) + /// 2027-01-15 04:00:00 UTC — four hours back; bar index 19 on a 24-bar axis. + static let fourHoursAgo = Date(timeIntervalSince1970: 1_799_985_600) + /// 2027-01-14 09:00:00 UTC — the oldest bar on the axis. + static let oldestHour = Date(timeIntervalSince1970: 1_799_917_200) + /// 2027-01-13 06:00:00 UTC — 27 hours before the oldest bar, well off the + /// left edge of the axis. + static let offAxis = Date(timeIntervalSince1970: 1_799_820_000) + + static let utc = TimeZone(identifier: "UTC")! + + static func bucket(start: Date, calls: Int, errors: Int) -> UsageBucket { + UsageBucket(start: start, calls: calls, errors: errors, totalRespBytes: 0) + } +} + +final class ActivityHistogramBarsTests: XCTestCase { + + /// The usage endpoint omits hours with no activity, so the timeline is + /// sparse. The axis must still be a stable 24 hours, oldest first, ending + /// at the hour containing `now`. + func testMissingHoursAreSynthesisedAsZero() { + let timeline = [ + // 08:20 UTC — must land in the 08:00 bucket, not its own. + Fixture.bucket(start: Date(timeIntervalSince1970: 1_800_001_200), calls: 10, errors: 3), + Fixture.bucket(start: Fixture.fourHoursAgo, calls: 4, errors: 0) + ] + + let bars = ActivityHistogram.bars(from: timeline, now: Fixture.now) + + XCTAssertEqual(bars.count, 24) + XCTAssertEqual(bars.first?.hourStart, Fixture.oldestHour) + XCTAssertEqual(bars.last?.hourStart, Fixture.currentHour) + XCTAssertEqual(bars[19].hourStart, Fixture.fourHoursAgo) + XCTAssertEqual(bars[19].succeeded, 4) + XCTAssertEqual(bars[19].errors, 0) + XCTAssertEqual(bars.filter { $0.total > 0 }.count, 2, "every other hour is a synthesised zero") + } + + /// A bucket's `calls` ALREADY includes its `errors`. The two stacked + /// segments must therefore sum to `calls`, never to `calls + errors`. + func testStackedSegmentsDoNotDoubleCountErrors() { + let timeline = [Fixture.bucket(start: Fixture.currentHour, calls: 10, errors: 3)] + + let bars = ActivityHistogram.bars(from: timeline, now: Fixture.now) + + XCTAssertEqual(bars[23].succeeded, 7) + XCTAssertEqual(bars[23].errors, 3) + XCTAssertEqual(bars[23].total, 10) + } + + /// Defensive: a bucket claiming more errors than calls must not produce a + /// negative segment — Charts would draw it below the axis. + func testErrorsExceedingCallsClampSucceededToZero() { + let timeline = [Fixture.bucket(start: Fixture.currentHour, calls: 2, errors: 5)] + + let bars = ActivityHistogram.bars(from: timeline, now: Fixture.now) + + XCTAssertEqual(bars[23].succeeded, 0) + XCTAssertEqual(bars[23].errors, 5) + } + + /// Buckets older than the axis are dropped, not folded into the oldest bar + /// (which would make yesterday's spike look like this morning's). + func testBucketsOlderThanTheAxisAreDropped() { + let timeline = [Fixture.bucket(start: Fixture.offAxis, calls: 99, errors: 9)] + + let bars = ActivityHistogram.bars(from: timeline, now: Fixture.now) + + XCTAssertEqual(bars.reduce(0) { $0 + $1.total }, 0) + } +} + +final class ActivityHistogramAccessibilityTests: XCTestCase { + + /// A bar chart is opaque to VoiceOver, so the hosted item carries one + /// sentence describing the whole series. + func testSummaryReportsTotalsAndPeakHour() { + let timeline = [ + Fixture.bucket(start: Fixture.currentHour, calls: 10, errors: 3), + Fixture.bucket(start: Fixture.fourHoursAgo, calls: 4, errors: 0) + ] + let bars = ActivityHistogram.bars(from: timeline, now: Fixture.now) + + let summary = ActivityHistogram.accessibilitySummary(bars: bars, timeZone: Fixture.utc) + + XCTAssertEqual( + summary, + "Activity over the last 24 hours: 14 calls, 3 errors. Busiest hour 08:00 with 10 calls." + ) + } + + /// "Loaded but idle" must read as idle, not as a broken chart. + func testSummaryForAnIdleTimelineSaysSo() { + let bars = ActivityHistogram.bars(from: [], now: Fixture.now) + + XCTAssertEqual( + ActivityHistogram.accessibilitySummary(bars: bars, timeZone: Fixture.utc), + "Activity over the last 24 hours: no tool calls." + ) + } +} + +final class ActivityHistogramStateTests: XCTestCase { + + /// `usageTimeline == nil` alone means both "not loaded yet" and "the fetch + /// failed", so the two are resolved against `usageError` — and a loaded + /// timeline beats a recorded failure, because real (if slightly stale) data + /// is worth more than an error row. + func testStateResolution() { + XCTAssertEqual( + ActivityHistogram.state(timeline: nil, errorMessage: nil, now: Fixture.now), + .loading + ) + XCTAssertEqual( + ActivityHistogram.state(timeline: nil, errorMessage: "", now: Fixture.now), + .loading, + "an empty message is not a failure" + ) + XCTAssertEqual( + ActivityHistogram.state(timeline: nil, errorMessage: "boom", now: Fixture.now), + .failed("boom") + ) + XCTAssertEqual( + ActivityHistogram.state(timeline: [], errorMessage: nil, now: Fixture.now), + .loaded(ActivityHistogram.bars(from: [], now: Fixture.now)) + ) + } + + /// A loaded-but-idle timeline is a flat 24-hour axis, deliberately distinct + /// from the loading state rather than collapsed into it. + func testAnIdleTimelineIsLoadedNotLoading() { + guard case .loaded(let bars) = ActivityHistogram.state( + timeline: [], errorMessage: "boom", now: Fixture.now + ) else { + return XCTFail("an empty timeline is loaded, and beats a stale failure") + } + XCTAssertEqual(bars.count, 24) + XCTAssertEqual(bars.reduce(0) { $0 + $1.total }, 0) + } +} + +@MainActor +final class AppStateUsageErrorTests: XCTestCase { + + /// A connected core. Every glance updater on `AppState` — this one included + /// — ignores writes unless `coreState == .connected`, so a test that skips + /// this asserts on a no-op. + private func connectedState() -> AppState { + let state = AppState() + state.coreState = .connected + return state + } + + func testRecordUsageFailureStoresTheMessage() { + let state = connectedState() + + XCTAssertNil(state.usageError) + state.recordUsageFailure("connection refused") + XCTAssertEqual(state.usageError, "connection refused") + } + + /// The same reconnect hazard `updateGlanceActivity` guards against, on the + /// failure path: a usage fetch already past its `guard let apiClient` when + /// the core dies resolves into its catch block AFTER `clearGlanceState()`. + /// Without the guard the dead core's failure would outlive it and the + /// submenu would say "Usage unavailable" where "Loading…" is the truth. + func testRecordUsageFailureIsIgnoredWhileDisconnected() { + let state = AppState() + + state.recordUsageFailure("connection refused") + + XCTAssertNil(state.usageError) + } + + /// A successful refresh must clear a stale failure, otherwise the submenu + /// would show the error row forever once a single fetch had failed. + func testUpdateUsageClearsAPreviousFailure() { + let state = connectedState() + state.recordUsageFailure("connection refused") + + state.updateUsage( + timeline: [Fixture.bucket(start: Fixture.currentHour, calls: 1, errors: 0)], + now: Fixture.now + ) + + XCTAssertNil(state.usageError) + XCTAssertEqual(state.callsThisHour, 1) + } + + /// Disconnecting must not leave the previous core's failure on screen. + func testClearGlanceStateClearsTheFailure() { + let state = connectedState() + state.recordUsageFailure("connection refused") + + state.clearGlanceState() + + XCTAssertNil(state.usageError) + } +} + + +/// The histogram submenu belongs to `GlanceSection` — there is exactly one, and +/// it builds its single row when it opens rather than on every `rebuildMenu()`. +/// These tests drive it through the delegate the section installs, which is the +/// same path AppKit uses. +@MainActor +final class GlanceHistogramSubmenuTests: XCTestCase { + + private final class ClickStub: NSObject { + @objc func openGlanceRow(_ sender: NSMenuItem) {} + } + + private static let clickStub = ClickStub() + + /// Every section built during a test, kept alive for its whole duration. + /// The section is the only strong reference to the submenu delegate — the + /// menu's own is weak — so letting one die mid-test empties the submenu. + private var sections: [GlanceSection] = [] + + override func tearDown() { + sections = [] + super.tearDown() + } + + /// A connected core — the block is hidden otherwise. + private func connectedState() -> AppState { + let state = AppState() + state.coreState = .connected + return state + } + + /// A section whose chart row is stubbed, so these tests assert on submenu + /// structure alone, independent of how the chart itself renders. + private func makeSection() -> GlanceSection { + let section = makeBareSection() + section.histogramChartItemFactory = { bars in + NSMenuItem(title: "CHART:\(bars.count)", action: nil, keyEquivalent: "") + } + return section + } + + /// A section with nothing injected, so the production defaults apply. + private func makeBareSection() -> GlanceSection { + let section = GlanceSection(target: Self.clickStub, + action: #selector(ClickStub.openGlanceRow(_:))) + sections.append(section) + return section + } + + /// The "Activity (24h)" item, wherever it sits in the block. + private func histogramItem(_ section: GlanceSection, _ state: AppState) -> NSMenuItem { + let items = section.items(for: state, now: Fixture.now) + guard let item = items.first(where: { $0.title == "Activity (24h)" }) else { + XCTFail("no Activity (24h) item in the block") + return NSMenuItem() + } + return item + } + + /// Fire the delegate the way AppKit does, through the menu's own reference, + /// so a delegate that was never installed fails the test. + private func open(_ menu: NSMenu) { + guard let delegate = menu.delegate else { + return XCTFail("the submenu has no delegate, so opening it would build nothing") + } + delegate.menuNeedsUpdate?(menu) + } + + /// Nothing is built until the submenu opens. `rebuildMenu()` runs on every + /// debounced state change, menu open or closed, so building the chart there + /// would render a SwiftUI Chart nobody is looking at. + func testSubmenuIsEmptyUntilItOpens() { + let item = histogramItem(makeSection(), connectedState()) + + XCTAssertEqual(item.submenu?.numberOfItems, 0) + } + + /// `NSMenu.delegate` is a WEAK reference: if the section does not retain the + /// delegate it deallocates the moment `items(for:)` returns, and the submenu + /// silently opens empty forever. Nothing but a test catches that. + func testTheDelegateOutlivesTheBuildCall() { + let section = makeSection() + let item = histogramItem(section, connectedState()) + + XCTAssertNotNil(item.submenu?.delegate, + "the section must retain the submenu delegate") + } + + /// The submenu delegate must be its own object, not the section's owner: + /// `AppController.menuWillOpen` rebuilds the whole tray menu, and having it + /// fire for a submenu opening under the cursor is exactly the + /// restructuring-while-open the design forbids. + func testTheSubmenuHasItsOwnDelegateNotTheTrayMenusOwner() { + let section = makeSection() + let item = histogramItem(section, connectedState()) + + let delegate = item.submenu?.delegate + XCTAssertNotNil(delegate) + XCTAssertFalse(delegate === Self.clickStub) + XCTAssertFalse(delegate === section as AnyObject) + } + + func testLoadingRowWhileTheTimelineIsNil() { + let menu = histogramItem(makeSection(), connectedState()).submenu! + + open(menu) + + XCTAssertEqual(menu.numberOfItems, 1) + XCTAssertEqual(menu.items[0].title, "Loading…") + XCTAssertFalse(menu.items[0].isEnabled) + let attributes = menu.items[0].attributedTitle!.attributes(at: 0, effectiveRange: nil) + XCTAssertEqual(attributes[.foregroundColor] as? NSColor, NSColor.secondaryLabelColor) + } + + func testErrorRowWhenTheFetchFailedBeforeAnyTimelineArrived() { + let state = connectedState() + state.recordUsageFailure("connection refused") + let menu = histogramItem(makeSection(), state).submenu! + + open(menu) + + XCTAssertEqual(menu.numberOfItems, 1) + XCTAssertEqual(menu.items[0].title, "Usage unavailable") + XCTAssertEqual(menu.items[0].toolTip, "connection refused") + XCTAssertFalse(menu.items[0].isEnabled) + let attributes = menu.items[0].attributedTitle!.attributes(at: 0, effectiveRange: nil) + XCTAssertEqual(attributes[.foregroundColor] as? NSColor, NSColor.secondaryLabelColor) + } + + /// The case that made the block confidently wrong: a timeline is loaded, so + /// `ActivityHistogram.state()` charts it and the recorded failure is never + /// rendered — every 30 seconds, forever. Real data still wins the chart row, + /// but a failure that keeps happening now gets a row of its own above it. + func testAPersistentFailureIsShownEvenWithALoadedTimeline() { + let state = connectedState() + state.usageTimeline = [Fixture.bucket(start: Fixture.currentHour, calls: 3, errors: 1)] + for _ in 0.. UsageAggregateResponse { + if let error { throw error } + return UsageAggregateResponse(window: "24h", tokenSource: "bytes", tokensSaved: 0, + tokensSavedPercentage: 0, timeline: timeline) + } + func glanceActivity(limit: Int) async throws -> [ActivityEntry] { [] } + func activeSessions(limit: Int) async throws -> [APIClient.MCPSession] { [] } + } + + private func connectedState() -> AppState { + let state = AppState() + state.coreState = .connected + return state + } + + func testAFailedRefreshRecordsTheMessage() async { + let state = connectedState() + let source = StubSource() + source.error = APIClientError.httpError(statusCode: 503, message: "core restarting") + + await state.refreshUsage(from: source) + + XCTAssertEqual(state.usageError, "HTTP 503: core restarting") + XCTAssertNil(state.usageTimeline, "a failed fetch must not fabricate a timeline") + } + + func testASuccessfulRefreshClearsAPreviousFailure() async { + let state = connectedState() + let source = StubSource() + source.error = APIClientError.notReady + await state.refreshUsage(from: source) + XCTAssertNotNil(state.usageError) + + source.error = nil + source.timeline = [Fixture.bucket(start: Fixture.currentHour, calls: 5, errors: 1)] + await state.refreshUsage(from: source) + + XCTAssertNil(state.usageError) + XCTAssertEqual(state.usageTimeline?.count, 1) + } + + /// A later failure must not throw away data already on screen — the chart + /// keeps showing real, slightly stale numbers rather than flipping to an + /// error row. + func testAFailedRefreshLeavesAnAlreadyLoadedTimelineAlone() async { + let state = connectedState() + let source = StubSource() + source.timeline = [Fixture.bucket(start: Fixture.currentHour, calls: 5, errors: 1)] + await state.refreshUsage(from: source) + + source.error = APIClientError.noData + await state.refreshUsage(from: source) + + XCTAssertEqual(state.usageTimeline?.count, 1, "the loaded timeline survives a later failure") + if case .loaded = ActivityHistogram.state(timeline: state.usageTimeline, + errorMessage: state.usageError, + now: Fixture.now) { + // expected: real data still beats the recorded failure + } else { + XCTFail("the submenu must keep charting data it already has") + } + } +} + +/// The chart's visual encoding. `GlanceSection.statusTint` states the rule these +/// pin: colour is never the only channel separating two outcomes. +/// +/// Rasterised with SwiftUI's `ImageRenderer`, NOT `NSHostingView.cacheDisplay`, +/// and at a pinned scale. Both halves come from a CI failure worth recording, +/// because the raster on the GitHub macOS runner is not what "it renders blank" +/// would suggest. +/// +/// What the runner actually produced: solid shapes yes — the legend's red +/// swatch came through — axis text apparently not, and all of it at 1x where +/// this machine is 2x. `cacheDisplay`'s output therefore varies with the host in +/// both content and pixel grid. `ImageRenderer` draws offscreen through Core +/// Graphics, takes an explicit `scale`, and matched the on-screen raster here +/// where it counts: identical red and chroma counts, measured both ways before +/// this was changed. Nothing below depends on text rendering. +/// +/// The scale mattered independently. The old sampler strode `rep.size`, which is +/// the POINT size at 2x and the PIXEL size at 1x, so one line of code covered a +/// quarter of the image on a Retina machine and all of it on the runner — which +/// is precisely why the legend's red swatch was invisible here and fatal there. +@MainActor +final class ActivityHistogramEncodingTests: XCTestCase { + + /// The chart is 132 pt tall and the bottom 24 pt of that is the legend — + /// the band the frame grew to pay for. Everything above it is the plot. + private static let legendBandHeight = 24.0 + + /// What a rasterised region actually painted. + private struct Pixels { + /// Sample points with meaningful alpha — i.e. how much was drawn at all. + /// + /// The threshold is deliberately low: `Color.secondary` resolves to an + /// alpha of ~0.5, so the success bars are semi-transparent against the + /// renderer's transparent backing. A 0.5 cut-off drops the entire + /// success series and leaves a "blank chart" reading that is not true. + var drawn = 0 + /// Sample points reading as saturated red. + var red = 0 + /// Sample points carrying ANY chroma: `max(r,g,b) - min(r,g,b)` above a + /// threshold. Greys — including `Color.secondary` — score zero. + var chromatic = 0 + } + + /// Which part of the chart to count. + private enum Region { + /// Everything above the legend — the plot area proper. + case plot + /// The bottom 24 pt, where the legend sits. + case legend + case whole + } + + /// Rasterise the chart offscreen and count pixels in one region. + /// + /// Returns nil ONLY when there is genuinely no image to inspect. It + /// deliberately does NOT fold a low pixel count into nil: a test that turns + /// its own failed precondition into a skip reports green while asserting + /// nothing, and does so exactly where nobody is watching. Callers assert on + /// `drawn` instead. + private func pixels(of bars: [HistogramBar], in region: Region) -> Pixels? { + let renderer = ImageRenderer(content: ActivityHistogramView(bars: bars, + accessibilitySummary: "")) + renderer.scale = 2 + guard let cgImage = renderer.cgImage else { return nil } + let rep = NSBitmapImageRep(cgImage: cgImage) + + // y grows downward in the rendered image, so the legend is the last rows. + let bandStart = rep.pixelsHigh - Int((Self.legendBandHeight / ActivityHistogram.chartItemSize.height) + * Double(rep.pixelsHigh)) + let rows: Range + switch region { + case .plot: rows = 0.. 0.1 else { continue } + counts.drawn += 1 + + let r = colour.redComponent, g = colour.greenComponent, b = colour.blueComponent + if r > 0.5, g < 0.45, b < 0.45 { counts.red += 1 } + if max(r, max(g, b)) - min(r, min(g, b)) > 0.15 { counts.chromatic += 1 } + } + } + return counts + } + + /// Rasterise, or FAIL. + /// + /// Deliberately not a skip, and this is the third narrowing of the same + /// hole: first a failed threshold turned into a skip, then a blank raster + /// did, and both times the shape that survived was "the environment where + /// these tests assert nothing reports success". There is no environment in + /// which a macOS build can legitimately fail to produce a CGImage from + /// `ImageRenderer` and the suite should still pass: the tray draws this + /// chart with the same machinery, so a host that cannot rasterise it cannot + /// run the feature either. If that ever becomes false, the honest change is + /// a named, asserted condition — not a skip that swallows every cause. + private func measure(_ bars: [HistogramBar], in region: Region = .whole) throws -> Pixels { + try XCTUnwrap(pixels(of: bars, in: region), + "ImageRenderer produced no image; the chart cannot be rasterised here at all") + } + + /// The success series must actually be painted. + /// + /// Nothing else here proves it. `drawn` counts axes, grid lines and tick + /// labels too, so every other assertion in this file is satisfied by a chart + /// whose success bars are invisible — setting the success fill to + /// `Color.clear` left all three of them green. The no-chroma test is the + /// worst of them: with no bars at all it passes more easily, since the thing + /// it measures is the absence of colour. + /// + /// Measured like for like: the same single-hour axis, one hour with ten + /// successes and one with none. The axes are identical, so the difference is + /// the bar. + func testSuccessSegmentsAreActuallyPainted() throws { + let hour = Fixture.currentHour + + let withSuccesses = try measure([HistogramBar(hourStart: hour, succeeded: 10, errors: 0)], in: .plot) + let empty = try measure([HistogramBar(hourStart: hour, succeeded: 0, errors: 0)], in: .plot) + + XCTAssertGreaterThan( + withSuccesses.drawn, empty.drawn + 1000, + "ten successes must paint a bar: \(withSuccesses.drawn) samples against \(empty.drawn) for an empty hour" + ) + } + + /// Errors must be drawn in a visually distinct fill, and that fill must be + /// driven by the DATA — an axis or legend that is red regardless would pass + /// a "chart contains red" assertion while showing failures in the same + /// colour as successes. + /// + /// Measured over the plot area alone. The legend is red in BOTH renders by + /// design, and while comparing shares cancels any data-independent red, not + /// including it says what is meant. + func testErrorSegmentsAreDrawnDistinctlyFromSuccesses() throws { + let hour = Fixture.currentHour + + let withErrors = try measure([HistogramBar(hourStart: hour, succeeded: 7, errors: 3)], in: .plot) + let withoutErrors = try measure([HistogramBar(hourStart: hour, succeeded: 10, errors: 0)], in: .plot) + + // Asserted, never skipped: a blank render must fail loudly, or the + // shares below are 0/0. + XCTAssertGreaterThan(withErrors.drawn, 500, "the chart rendered blank; the counts mean nothing") + XCTAssertGreaterThan(withoutErrors.drawn, 500, "the chart rendered blank; the counts mean nothing") + + let withShare = Double(withErrors.red) / Double(withErrors.drawn) + let withoutShare = Double(withoutErrors.red) / Double(withoutErrors.drawn) + XCTAssertGreaterThan( + withShare, withoutShare + 0.05, + "an hour with errors must paint materially more of the error colour than one without" + ) + } + + /// The success fill must not follow `Color.accentColor`, or a user whose + /// system accent is red sees two near-identical segments. + /// + /// The accent cannot be set from a test process, so this measures the + /// property from the other side: `Color.secondary` is achromatic, an accent + /// is not. A success-only PLOT must therefore paint no chroma. The failure + /// direction is safe — a machine whose accent is Graphite renders a grey + /// accent and gives a false pass, never a false fail. + /// + /// Scoped to the plot for a reason CI had to teach us: the legend carries a + /// red "Errors" swatch in every render, success-only included, so asserting + /// no chroma over the whole image asserts something false. It passed locally + /// only because the old sampler happened to miss the legend band, and failed + /// on the runner — 13 chromatic samples — where the same code covered it. + func testSuccessFillCarriesNoChromaAndSoCannotBeTheAccentColour() throws { + let counts = try measure([HistogramBar(hourStart: Fixture.currentHour, + succeeded: 10, errors: 0)], in: .plot) + + XCTAssertGreaterThan(counts.drawn, 500, "the chart rendered blank; the count below means nothing") + XCTAssertEqual( + counts.chromatic, 0, + "a plot with no errors must paint nothing coloured; \(counts.chromatic) of \(counts.drawn) sampled pixels carried chroma" + ) + } + + /// The legend must actually render, and inside the frame that was grown to + /// pay for it. + /// + /// `.chartLegend(.visible)` was the one user-visible thing on this view that + /// no test asserted the presence of: flipping it to `.hidden` left the suite + /// green while silently reclaiming the 20pt the frame grew to fit it — and + /// `testRealChartItemIsSizedAndLabelled` would then have failed on the size, + /// inviting whoever reclaimed the space to "fix" that test instead. + /// + /// Measured, not asserted from the outside: on an all-zero axis the chart + /// draws no error segments, so the ONLY red the view can contain is the + /// legend's "Errors" swatch. Hiding the legend takes it to zero. Both halves + /// are positive assertions, so a blank raster fails them rather than passing. + func testTheLegendRendersInsideTheFrameThatPaysForIt() throws { + let bars = ActivityHistogram.bars(from: [], now: Fixture.now) + + let legend = try measure(bars, in: .legend) + let plot = try measure(bars, in: .plot) + + XCTAssertGreaterThan( + legend.red, 0, + "no legend swatch in the bottom band — the 20pt of frame height buys nothing" + ) + XCTAssertEqual( + plot.red, 0, + "an all-zero axis must paint no red in the plot, or this measures chart data" + ) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift new file mode 100644 index 000000000..c26cbef99 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/AppStateGlanceTests.swift @@ -0,0 +1,468 @@ +import XCTest +import Combine +@testable import MCPProxy + +/// Tray Glance: the four `AppState` glance fields, the current-UTC-hour +/// derivation behind `callsThisHour`, the disconnect reset, and the guarantee +/// that the shared Dashboard/ActivityView feeds are not narrowed. +@MainActor +final class AppStateGlanceTests: XCTestCase { + + // MARK: - callsThisHour + + /// A sparse timeline whose newest bucket is three hours old must report + /// zero calls this hour, NOT that bucket's count. Buckets are UTC-hour + /// aligned and the endpoint omits hours with no activity, so "the most + /// recent bucket" is a count from the past presented as if it were current. + func testCallsThisHourIsZeroWhenCurrentHourBucketIsAbsent() throws { + let now = Self.date("2026-07-29T14:05:00Z") + let timeline = [ + Self.bucket("2026-07-29T10:00:00Z", calls: 7), + Self.bucket("2026-07-29T11:00:00Z", calls: 12, errors: 2), + ] + + let state = AppState() + state.coreState = .connected + state.updateUsage(timeline: timeline, now: now) + + XCTAssertEqual(state.callsThisHour, 0) + XCTAssertEqual(state.usageTimeline?.count, 2) + } + + /// When the current UTC hour does have a bucket, its `calls` is the headline + /// number — regardless of where it sits in the array. + func testCallsThisHourReadsTheCurrentHourBucket() throws { + let now = Self.date("2026-07-29T11:42:31Z") + let timeline = [ + Self.bucket("2026-07-29T11:00:00Z", calls: 12, errors: 2), + Self.bucket("2026-07-29T10:00:00Z", calls: 7), + ] + + let state = AppState() + state.coreState = .connected + state.updateUsage(timeline: timeline, now: now) + + XCTAssertEqual(state.callsThisHour, 12) + } + + /// "Loaded, and the proxy was idle" must be expressible: an empty timeline + /// yields 0, which is deliberately distinct from the nil loading state. + func testEmptyTimelineYieldsZeroNotNil() { + let state = AppState() + XCTAssertNil(state.callsThisHour, "callsThisHour starts nil = not loaded yet") + + state.coreState = .connected + state.updateUsage(timeline: [], now: Self.date("2026-07-29T11:42:00Z")) + + XCTAssertEqual(state.callsThisHour, 0) + XCTAssertEqual(state.usageTimeline, []) + } + + // MARK: - Disconnect reset + + /// The connect path flips to `.connected` before the first refresh + /// completes, so glance state from a previous core must be cleared the + /// moment the core state leaves `.connected`. + func testGlanceStateClearedOnDisconnect() throws { + let state = AppState() + state.coreState = .connected + state.updateGlanceActivity([try Self.activity(id: "a1", type: "tool_call")]) + state.updateGlanceSessions([try Self.session(id: "s1", status: "active")]) + state.updateUsage( + timeline: [Self.bucket("2026-07-29T11:00:00Z", calls: 12)], + now: Self.date("2026-07-29T11:10:00Z") + ) + + state.coreState = .idle + + XCTAssertTrue(state.glanceActivity.isEmpty) + XCTAssertTrue(state.glanceSessions.isEmpty) + XCTAssertNil(state.usageTimeline) + XCTAssertNil(state.callsThisHour) + } + + /// Every non-connected state clears, not just `.idle` — a reconnecting or + /// errored core must not keep showing the old numbers either. + func testGlanceStateClearedOnReconnectingAndError() throws { + for target in [CoreState.reconnecting(attempt: 1), .error(.general("boom")), .shuttingDown] { + let state = AppState() + state.coreState = .connected + state.updateGlanceActivity([try Self.activity(id: "a1", type: "tool_call")]) + state.callsThisHour = 9 + + state.coreState = target + + XCTAssertTrue(state.glanceActivity.isEmpty, "\(target) should clear glanceActivity") + XCTAssertNil(state.callsThisHour, "\(target) should clear callsThisHour") + } + } + + /// Staying connected must not wipe the feeds the refresh loop just filled. + func testConnectedStateDoesNotClearGlanceState() throws { + let state = AppState() + state.coreState = .connected + state.updateGlanceActivity([try Self.activity(id: "a1", type: "tool_call")]) + state.callsThisHour = 4 + + state.coreState = .connected + + XCTAssertEqual(state.glanceActivity.map(\.id), ["a1"]) + XCTAssertEqual(state.callsThisHour, 4) + } + + /// A glance fetch already past its `guard let apiClient` when the core leaves + /// `.connected` resolves AFTER the reset and would otherwise write the dead + /// core's data back over the cleared fields, silently undoing the one + /// guarantee these fields exist to provide. + /// + /// The window is real, not theoretical: `CoreProcessManager.shutdown()` + /// transitions to `.shuttingDown` (:204) before it cancels `refreshTask` + /// (:210), and cancellation would not help anyway — a suspended + /// `await apiClient.glanceActivity()` still resumes and still runs the + /// `await appState.update…` that follows it. + func testLateFetchDoesNotRepopulateAfterDisconnect() throws { + let state = AppState() + state.coreState = .connected + state.updateGlanceActivity([try Self.activity(id: "a1", type: "tool_call")]) + state.updateGlanceSessions([try Self.session(id: "s1", status: "active")]) + state.updateUsage( + timeline: [Self.bucket("2026-07-29T11:00:00Z", calls: 12)], + now: Self.date("2026-07-29T11:10:00Z") + ) + + state.coreState = .shuttingDown + + // The three in-flight fetches resolve now, after the reset. + state.updateGlanceActivity([try Self.activity(id: "a2", type: "tool_call")]) + state.updateGlanceSessions([try Self.session(id: "s2", status: "active")]) + state.updateUsage( + timeline: [Self.bucket("2026-07-29T11:00:00Z", calls: 99)], + now: Self.date("2026-07-29T11:10:00Z") + ) + + XCTAssertTrue(state.glanceActivity.isEmpty, "a late activity fetch must not repopulate") + XCTAssertTrue(state.glanceSessions.isEmpty, "a late sessions fetch must not repopulate") + XCTAssertNil(state.usageTimeline, "a late usage fetch must not repopulate") + XCTAssertNil(state.callsThisHour, "a late usage fetch must not repopulate") + } + + // MARK: - Reconciling poll + + /// The 30s poll exists to reconcile the SSE-fed optimistic list with the + /// server's canonical records — including the asynchronously-computed + /// sensitive-data flag and the final status, which arrive on a record whose + /// id has NOT changed. `ActivityEntry`'s Equatable is id-only + /// (API/Models.swift:570), so an id-only guard would drop those corrections + /// forever and the row would lie until it scrolled off. + func testGlanceActivityUpdatesWhenOnlyStatusChanges() throws { + let state = AppState() + state.coreState = .connected + state.updateGlanceActivity([try Self.activity(id: "a1", type: "tool_call")]) + state.updateGlanceActivity([try Self.activity(id: "a1", type: "tool_call", status: "error")]) + + XCTAssertEqual(state.glanceActivity.first?.status, "error") + } + + // MARK: - Reconcile vs live SSE rows + + /// `AppState` is reached through `await`, and the poll suspends on the + /// network for tens of milliseconds. An SSE event that lands inside that + /// window prepends a row the in-flight response cannot know about, and a + /// wholesale replace erases it — the user watches a call appear and then + /// vanish for up to 30 seconds. The merge keeps it. + func testAPollDoesNotEraseARowThatArrivedWhileItWasInFlight() throws { + let state = AppState() + state.coreState = .connected + let page = [try Self.activity(id: "a1", type: "tool_call", request: "r-1", + timestamp: "2026-07-29T11:00:00Z")] + state.updateGlanceActivity(page) + + // An SSE event lands while the next poll is suspended… + state.prependGlanceActivity(try Self.activity(id: "r-9:tool_call", type: "tool_call", + request: "r-9", + timestamp: "2026-07-29T11:00:30Z"), + generation: state.connectionGeneration) + // …and the poll's response, which predates it, resolves now. + state.updateGlanceActivity(page) + + XCTAssertEqual(state.glanceActivity.map(\.requestId), ["r-9", "r-1"]) + } + + /// Once the poll DOES carry the call, its canonical record replaces the + /// optimistic row rather than joining it: both describe one call, and + /// `requestId` is what says so — the storage id differs from the SSE row's + /// provisional `:`. + /// + /// The SSE row is stamped a shade LATER than the record, which is the case + /// that separates the two keys: an id-keyed merge finds no match, sees a row + /// newer than the page, retains it, and the one call occupies two rows. + func testThePolledRecordSupersedesTheLiveRowForTheSameCall() throws { + let state = AppState() + state.coreState = .connected + state.prependGlanceActivity(try Self.activity(id: "r-9:tool_call", type: "tool_call", + request: "r-9", + timestamp: "2026-07-29T11:00:30.500Z"), + generation: state.connectionGeneration) + + state.updateGlanceActivity([ + try Self.activity(id: "01JQ-STORAGE-ULID", type: "tool_call", request: "r-9", + timestamp: "2026-07-29T11:00:30Z") + ]) + + XCTAssertEqual(state.glanceActivity.map(\.id), ["01JQ-STORAGE-ULID"], + "one call, one row — keyed on requestId, not on the churning id") + } + + /// The merge must not turn the feed into an append-only log. A row the poll + /// omits from WITHIN its own window — collapsed, pruned, filtered — is the + /// poll's business, and only rows newer than everything the page carries + /// are retained. + func testTheMergeDropsAStaleRowInsideThePollsOwnWindow() throws { + let state = AppState() + state.coreState = .connected + state.updateGlanceActivity([ + try Self.activity(id: "gone", type: "tool_call", request: "r-old", + timestamp: "2026-07-29T10:59:00Z") + ]) + + state.updateGlanceActivity([ + try Self.activity(id: "a1", type: "tool_call", request: "r-1", + timestamp: "2026-07-29T11:00:00Z") + ]) + + XCTAssertEqual(state.glanceActivity.map(\.id), ["a1"]) + } + + /// An empty page contradicts nothing. A poll that returns no records has + /// nothing to say about a call the server had not recorded when it answered, + /// so it must not erase the row the SSE event just put on screen — the same + /// bug shape the merge was written to fix, in the one branch that used to + /// fall through to a replace. + func testAnEmptyPollDoesNotEraseALiveRow() throws { + let state = AppState() + state.coreState = .connected + state.prependGlanceActivity(try Self.activity(id: "r-9:tool_call", type: "tool_call", + request: "r-9", + timestamp: "2026-07-29T11:00:30Z"), + generation: state.connectionGeneration) + + state.updateGlanceActivity([]) + + XCTAssertEqual(state.glanceActivity.map(\.requestId), ["r-9"]) + } + + /// The empty-page retention must be NARROW: it exists for a row the server + /// has not written yet, not for one it has deliberately dropped. A canonical + /// row — one the poll itself delivered — that then disappears from the log + /// (retention, an explicit delete) must go when the server says it is gone, + /// and on an idle core "the next poll" never rescues it. + func testAnEmptyPollDropsACanonicalRowTheServerHasDeleted() throws { + let state = AppState() + state.coreState = .connected + state.updateGlanceActivity([ + try Self.activity(id: "a1", type: "tool_call", request: "r-1", + timestamp: "2026-07-29T11:00:00Z") + ]) + + state.updateGlanceActivity([]) + + XCTAssertTrue(state.glanceActivity.isEmpty, + "a row the server delivered and then dropped must not outlive it") + } + + /// Same rule with a non-empty page: a canonical row newer than everything + /// the page still carries is not thereby immune to deletion. + func testAPollDropsACanonicalRowNewerThanEverythingItStillCarries() throws { + let state = AppState() + state.coreState = .connected + state.updateGlanceActivity([ + try Self.activity(id: "newest", type: "tool_call", request: "r-2", + timestamp: "2026-07-29T11:05:00Z"), + try Self.activity(id: "older", type: "tool_call", request: "r-1", + timestamp: "2026-07-29T11:00:00Z") + ]) + + state.updateGlanceActivity([ + try Self.activity(id: "older", type: "tool_call", request: "r-1", + timestamp: "2026-07-29T11:00:00Z") + ]) + + XCTAssertEqual(state.glanceActivity.map(\.id), ["older"]) + } + + /// Once a poll has confirmed a live row, it stops being unconfirmed: it is + /// the server's record now, and a later deletion removes it like any other. + func testAConfirmedLiveRowIsNoLongerRetainedAsUnconfirmed() throws { + let state = AppState() + state.coreState = .connected + state.prependGlanceActivity(try Self.activity(id: "r-9:tool_call", type: "tool_call", + request: "r-9", + timestamp: "2026-07-29T11:00:30Z"), + generation: state.connectionGeneration) + state.updateGlanceActivity([ + try Self.activity(id: "01JQ-ULID", type: "tool_call", request: "r-9", + timestamp: "2026-07-29T11:00:30Z") + ]) + + state.updateGlanceActivity([]) + + XCTAssertTrue(state.glanceActivity.isEmpty) + } + + /// The unconfirmed-key set must not outlive the rows it describes. The feed + /// is capped; the set was not, so a healthy SSE stream with a failing poll — + /// the exact scenario the staleness marker exists for — grew it without + /// bound for as long as the core stayed up. + func testTheUnconfirmedKeySetCannotOutgrowTheCappedFeed() throws { + let state = AppState() + state.coreState = .connected + + for i in 0..<(AppState.glanceActivityCap + 25) { + state.prependGlanceActivity(try Self.activity(id: "live-\(i)", type: "tool_call", + request: "live-\(i)", + timestamp: "2026-07-29T12:00:00Z"), + generation: state.connectionGeneration) + } + + XCTAssertEqual(state.glanceActivity.count, AppState.glanceActivityCap) + XCTAssertEqual(state.unconfirmedLiveKeys.count, AppState.glanceActivityCap, + "a key whose row has been capped away describes nothing") + XCTAssertEqual(state.unconfirmedLiveKeys, + Set(state.glanceActivity.compactMap(\.requestId)), + "the set must name exactly the rows still in the feed") + } + + /// …but a page that HAS records and no usable timestamps still replaces. + /// "Newer than everything the page carries" is unanswerable there, and a + /// page full of records is the server's own account of the feed; only the + /// genuinely empty page is the non-statement. + func testAPageWithUnparsableTimestampsStillReplaces() throws { + let state = AppState() + state.coreState = .connected + state.prependGlanceActivity(try Self.activity(id: "r-9:tool_call", type: "tool_call", + request: "r-9", + timestamp: "2026-07-29T11:00:30Z"), + generation: state.connectionGeneration) + + state.updateGlanceActivity([ + try Self.activity(id: "a1", type: "tool_call", request: "r-1", timestamp: "not a date") + ]) + + XCTAssertEqual(state.glanceActivity.map(\.id), ["a1"]) + } + + /// Retention is bounded: a burst of SSE rows plus a full page cannot grow + /// the feed past the cap. + func testTheMergedFeedStaysCapped() throws { + let state = AppState() + state.coreState = .connected + for i in 0..<10 { + state.prependGlanceActivity(try Self.activity(id: "live-\(i)", type: "tool_call", + request: "live-\(i)", + timestamp: "2026-07-29T12:00:00Z"), + generation: state.connectionGeneration) + } + let page = try (0.. Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + // swiftlint:disable:next force_unwrapping + return formatter.date(from: iso)! + } + + private static func bucket(_ iso: String, calls: Int, errors: Int = 0) -> UsageBucket { + UsageBucket(start: date(iso), calls: calls, errors: errors, totalRespBytes: 0) + } + + static func activity(id: String, + type: String, + status: String = "success", + request: String? = nil, + timestamp: String = "2026-07-29T11:00:00Z") throws -> ActivityEntry { + let requestField = request.map { ",\"request_id\":\"\($0)\"" } ?? "" + let json = """ + {"id":"\(id)","type":"\(type)","status":"\(status)","timestamp":"\(timestamp)"\(requestField)} + """ + // swiftlint:disable:next force_unwrapping + return try JSONDecoder().decode(ActivityEntry.self, from: json.data(using: .utf8)!) + } + + private static func session(id: String, status: String, calls: Int = 3) throws -> APIClient.MCPSession { + let json = """ + {"id":"\(id)","client_name":"Claude Code","status":"\(status)","tool_call_count":\(calls)} + """ + // swiftlint:disable:next force_unwrapping + return try JSONDecoder().decode(APIClient.MCPSession.self, from: json.data(using: .utf8)!) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/CountingGlanceDataSource.swift b/native/macos/MCPProxy/MCPProxyTests/CountingGlanceDataSource.swift new file mode 100644 index 000000000..3808e59ea --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/CountingGlanceDataSource.swift @@ -0,0 +1,43 @@ +// CountingGlanceDataSource.swift +// MCPProxyTests +// +// A GlanceDataSource that performs no I/O and counts calls. Used to pin the +// spec-048 invariant: building the tray menu issues zero requests. + +import Foundation +@testable import MCPProxy + +final class CountingGlanceDataSource: GlanceDataSource { + + private(set) var usageCallCount = 0 + private(set) var activityCallCount = 0 + private(set) var sessionCallCount = 0 + + /// Total requests this data source was asked to make. + var totalCallCount: Int { usageCallCount + activityCallCount + sessionCallCount } + + var usageToReturn = UsageAggregateResponse( + window: "24h", + tokenSource: "bytes", + tokensSaved: 0, + tokensSavedPercentage: 0, + timeline: [] + ) + var activityToReturn: [ActivityEntry] = [] + var sessionsToReturn: [APIClient.MCPSession] = [] + + func usageAggregate(window: String, top: Int) async throws -> UsageAggregateResponse { + usageCallCount += 1 + return usageToReturn + } + + func glanceActivity(limit: Int) async throws -> [ActivityEntry] { + activityCallCount += 1 + return activityToReturn + } + + func activeSessions(limit: Int) async throws -> [APIClient.MCPSession] { + sessionCallCount += 1 + return sessionsToReturn + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceEventTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceEventTests.swift new file mode 100644 index 000000000..d5a2cc266 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceEventTests.swift @@ -0,0 +1,206 @@ +import XCTest +@testable import MCPProxy + +/// Tray Glance — SSE activity adapter. +/// +/// The core emits `activity.tool_call.completed` / +/// `activity.internal_tool_call.completed`, never `activity`, and the payload is +/// an envelope `{payload, timestamp}` whose timestamp is Unix SECONDS while +/// `ActivityEntry.timestamp` is an ISO-8601 string. These tests pin the mapping, +/// and assert it against the real consumers (`GlanceSelection`, +/// `GlanceFormatting`) rather than a locally rebuilt copy of their logic. +@MainActor +final class GlanceEventTests: XCTestCase { + + /// Upstream calls carry `server_name` / `tool_name` (event_bus.go + /// EmitActivityToolCallCompleted, payload literal at :444-454). + func testUpstreamCompletedPayloadBecomesToolCallEntry() throws { + let json = """ + {"payload":{"server_name":"github","tool_name":"create_issue", + "session_id":"sess-1","request_id":"req-1","source":"mcp","status":"success", + "error_message":"","duration_ms":142},"timestamp":1753800000} + """ + let entry = try XCTUnwrap(GlanceEvent.adapt( + eventName: "activity.tool_call.completed", + data: Data(json.utf8) + )) + + XCTAssertEqual(entry.type, "tool_call") + XCTAssertEqual(entry.serverName, "github") + XCTAssertEqual(entry.toolName, "create_issue") + XCTAssertEqual(entry.status, "success") + XCTAssertEqual(entry.durationMs, 142) + XCTAssertEqual(entry.sessionId, "sess-1") + XCTAssertEqual(entry.requestId, "req-1") + XCTAssertNil(entry.errorMessage, "empty error_message must not become a failure detail") + } + + /// Internal calls carry `internal_tool_name`, and `target_server` only when + /// non-empty (event_bus.go EmitActivityInternalToolCall, :552-565). Reading + /// `tool_name` here would produce a row with no tool at all — and the row + /// must survive the selection rules and the relative-time formatter, not + /// merely hold the right field values. + func testInternalCompletedPayloadUsesInternalToolNameAndTargetServer() throws { + let json = """ + {"payload":{"internal_tool_name":"call_tool_read","target_server":"jira", + "target_tool":"get_issue","session_id":"sess-2","request_id":"req-2", + "status":"error","error_message":"auth failed","duration_ms":9}, + "timestamp":1753800000} + """ + let entry = try XCTUnwrap(GlanceEvent.adapt( + eventName: "activity.internal_tool_call.completed", + data: Data(json.utf8) + )) + + XCTAssertEqual(entry.type, "internal_tool_call") + XCTAssertEqual(entry.toolName, "call_tool_read") + XCTAssertEqual(entry.serverName, "jira") + XCTAssertEqual(entry.status, "error") + XCTAssertTrue(GlanceSelection.qualifies(entry), + "a failed wrapper qualifies under rule 3") + XCTAssertEqual(GlanceFormatting.relativeTime( + entry.timestamp, + now: Date(timeIntervalSince1970: 1_753_800_012) + ), "12s", "the tray's own parser must accept the timestamp we emit") + } + + /// `target_server` is omitted for discovery built-ins such as + /// retrieve_tools, so the row must tolerate its absence. + func testInternalPayloadWithoutTargetServerHasNilServerName() throws { + let json = """ + {"payload":{"internal_tool_name":"retrieve_tools","session_id":"sess-3", + "request_id":"req-3","status":"success","duration_ms":4}, + "timestamp":1753800000} + """ + let entry = try XCTUnwrap(GlanceEvent.adapt( + eventName: "activity.internal_tool_call.completed", + data: Data(json.utf8) + )) + + XCTAssertEqual(entry.toolName, "retrieve_tools") + XCTAssertNil(entry.serverName) + } + + /// The core does not persist started events (activity_service.go), so a row + /// built from one would never be reconciled by the poll. + func testStartedEventIsIgnored() { + let json = """ + {"payload":{"server_name":"github","tool_name":"create_issue", + "request_id":"req-4"},"timestamp":1753800000} + """ + XCTAssertNil(GlanceEvent.adapt( + eventName: "activity.tool_call.started", + data: Data(json.utf8) + )) + } + + /// A failed upstream call emits BOTH events under ONE request id, and + /// `ActivityEntry` derives identity and equality from `id` alone — so a bare + /// request id would make the two records collide before rule 4 could pick + /// the `tool_call` one. The last assertion is the point of the composite id. + func testPairedEventsUnderOneRequestIdGetDistinctIds() throws { + let upstream = """ + {"payload":{"server_name":"jira","tool_name":"get_issue", + "request_id":"req-5","status":"error","error_message":"auth failed"}, + "timestamp":1753800000} + """ + let wrapper = """ + {"payload":{"internal_tool_name":"call_tool_read","target_server":"jira", + "request_id":"req-5","status":"error","error_message":"auth failed"}, + "timestamp":1753800000} + """ + let a = try XCTUnwrap(GlanceEvent.adapt( + eventName: "activity.tool_call.completed", data: Data(upstream.utf8))) + let b = try XCTUnwrap(GlanceEvent.adapt( + eventName: "activity.internal_tool_call.completed", data: Data(wrapper.utf8))) + + XCTAssertEqual(a.id, "req-5:tool_call") + XCTAssertEqual(b.id, "req-5:internal_tool_call") + XCTAssertNotEqual(a, b) + XCTAssertEqual(a.requestId, b.requestId, "the shared request id is what rule 4 collapses on") + XCTAssertEqual(GlanceSelection.activityRows(from: [a, b]).map(\.id), + ["req-5:tool_call"], + "rule 4 keeps the record that names the real server:tool") + } + + /// The payload key is `error_message`, matching `ActivityEntry` — a read + /// keyed on `error` would render a failed call as if it had no detail. + func testFailureDetailComesFromErrorMessageKey() throws { + let json = """ + {"payload":{"server_name":"jira","tool_name":"get_issue","request_id":"req-6", + "status":"error","error_message":"auth failed: token expired"}, + "timestamp":1753800000} + """ + let entry = try XCTUnwrap(GlanceEvent.adapt( + eventName: "activity.tool_call.completed", + data: Data(json.utf8) + )) + + XCTAssertEqual(entry.errorMessage, "auth failed: token expired") + } + + /// The envelope timestamp is Unix seconds; the entry must carry a string the + /// tray's OWN parser accepts. Rebuilding an ISO8601DateFormatter here would + /// let the test pass while GlanceFormatting rejected the string. + func testEnvelopeUnixSecondsBecomeParsableISO8601() throws { + let json = """ + {"payload":{"server_name":"github","tool_name":"create_issue", + "request_id":"req-7","status":"success"},"timestamp":1753800000} + """ + let entry = try XCTUnwrap(GlanceEvent.adapt( + eventName: "activity.tool_call.completed", + data: Data(json.utf8) + )) + + let parsed = try XCTUnwrap(GlanceFormatting.parseTimestamp(entry.timestamp)) + XCTAssertEqual(parsed.timeIntervalSince1970, 1_753_800_000, accuracy: 0.001) + } + + /// SSE rows go in newest-first and the feed is bounded, so a busy agent + /// cannot grow `glanceActivity` without limit between reconciling polls. + func testPrependPutsNewestFirstAndCapsTheFeed() throws { + let state = AppState() + state.coreState = .connected + + for index in 0..<(AppState.glanceActivityCap + 5) { + let json = """ + {"payload":{"server_name":"github","tool_name":"create_issue", + "request_id":"req-\(index)","status":"success"},"timestamp":1753800000} + """ + let entry = try XCTUnwrap(GlanceEvent.adapt( + eventName: "activity.tool_call.completed", + data: Data(json.utf8) + )) + state.prependGlanceActivity(entry, generation: state.connectionGeneration) + } + + XCTAssertEqual(state.glanceActivity.count, AppState.glanceActivityCap) + XCTAssertEqual(state.glanceActivity.first?.requestId, "req-\(AppState.glanceActivityCap + 4)") + XCTAssertTrue(state.recentActivity.isEmpty, "the shared Dashboard feed must not be touched") + } + + /// The three Task-3 `update*` helpers all ignore writes unless the core is + /// connected, because a late-resolving write would put a dead core's data + /// back over just-cleared state. SSE teardown is asynchronous, so an event + /// already in flight when the core drops reaches this path the same way. + func testPrependIsIgnoredWhenCoreIsNotConnected() throws { + let state = AppState() + state.coreState = .connected + let json = """ + {"payload":{"server_name":"github","tool_name":"create_issue", + "request_id":"req-late","status":"success"},"timestamp":1753800000} + """ + let entry = try XCTUnwrap(GlanceEvent.adapt( + eventName: "activity.tool_call.completed", + data: Data(json.utf8) + )) + + // `CoreProcessManager.shutdown()` transitions here before it cancels + // `refreshTask`, so this is the state a late event actually lands in. + state.coreState = .shuttingDown + state.prependGlanceActivity(entry, generation: state.connectionGeneration) + + XCTAssertTrue(state.glanceActivity.isEmpty, + "a row arriving after the core dropped must not repopulate the cleared feed") + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceFixtures.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceFixtures.swift new file mode 100644 index 000000000..8c066f18f --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceFixtures.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import MCPProxy + +/// Shared fixtures for the tray-glance menu tests. +/// `GlanceSectionTests` predates this file and keeps its own copies. +enum GlanceFixtures { + + /// Fixed clock so relative ages are deterministic. + static let now = GlanceFormatting.parseTimestamp("2027-01-15T08:00:00Z")! + + /// A connected core with two qualifying calls and one active client. + /// The second call is deliberately unattributed (no `session_id`). + static func connectedState() -> AppState { + let state = AppState() + // coreState first: its didSet clears the glance feeds on any non-connected state. + state.coreState = .connected + state.callsThisHour = 12 + state.glanceActivity = [ + entry(id: "a", server: "github", tool: "create_issue", + timestamp: "2027-01-15T07:59:30Z", session: "sess-a"), + entry(id: "b", server: "jira", tool: "get_issue", + timestamp: "2027-01-15T07:58:00Z", session: nil) + ] + state.glanceSessions = [ + session(id: "sess-a", name: "Claude Code", calls: 8, + lastActivity: "2027-01-15T07:59:00Z") + ] + return state + } + + static func entry( + id: String, + server: String, + tool: String, + timestamp: String, + session: String? + ) -> ActivityEntry { + var json: [String: Any] = [ + "id": id, + "type": "tool_call", + "status": "success", + "timestamp": timestamp, + "request_id": "req-\(id)", + "server_name": server, + "tool_name": tool + ] + if let session { json["session_id"] = session } + let data = try! JSONSerialization.data(withJSONObject: json) + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(ActivityEntry.self, from: data) + } + + static func session( + id: String, + name: String, + calls: Int, + lastActivity: String + ) -> APIClient.MCPSession { + let json: [String: Any] = [ + "id": id, + "status": "active", + "client_name": name, + "tool_call_count": calls, + "last_activity": lastActivity + ] + let data = try! JSONSerialization.data(withJSONObject: json) + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(APIClient.MCPSession.self, from: data) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceFormattingTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceFormattingTests.swift new file mode 100644 index 000000000..fff06260d --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceFormattingTests.swift @@ -0,0 +1,120 @@ +import XCTest +@testable import MCPProxy + +final class GlanceFormattingTests: XCTestCase { + + // MARK: - Status symbol + + func testStatusSymbolDistinguishesSuccessErrorAndOther() { + XCTAssertEqual(GlanceFormatting.statusSymbolName(for: Self.entry(status: "success")), "checkmark.circle") + XCTAssertEqual(GlanceFormatting.statusSymbolName(for: Self.entry(status: "error")), "xmark.circle") + XCTAssertEqual(GlanceFormatting.statusSymbolName(for: Self.entry(status: "blocked")), "exclamationmark.circle") + } + + // MARK: - Row label + + func testUpstreamCallLabelIsServerColonTool() { + let entry = Self.entry(type: "tool_call", server: "github", tool: "create_issue") + XCTAssertEqual(GlanceFormatting.rowLabel(for: entry), "github:create_issue") + } + + func testBuiltInLabelIsJustTheToolName() { + let entry = Self.entry(type: "internal_tool_call", tool: "retrieve_tools") + XCTAssertEqual(GlanceFormatting.rowLabel(for: entry), "retrieve_tools") + } + + func testAlreadyPrefixedToolNameIsNotDoubled() { + let entry = Self.entry(type: "tool_call", server: "github", tool: "github:create_issue") + XCTAssertEqual(GlanceFormatting.rowLabel(for: entry), "github:create_issue") + } + + func testBuiltInWithAServerNameStillLabelsAsTheBareBuiltIn() { + // A failed call_tool_* wrapper is persisted carrying BOTH a tool_name + // and the server_name it was dispatched at. Only `type == "tool_call"` + // composes `server:tool`, so this must stay the bare built-in name. + let entry = Self.entry(type: "internal_tool_call", server: "fixture", tool: "call_tool_read", status: "error") + XCTAssertEqual(GlanceFormatting.rowLabel(for: entry), "call_tool_read") + } + + func testLabelFallsBackToTypeWhenNothingIsNamed() { + let entry = Self.entry(type: "oauth_event") + XCTAssertEqual(GlanceFormatting.rowLabel(for: entry), "oauth_event") + } + + // MARK: - Truncation + + func testShortTextIsNotTruncated() { + XCTAssertEqual(GlanceFormatting.middleTruncated("github:create", limit: 20), "github:create") + } + + func testMiddleTruncationKeepsHeadAndTailAtExactlyTheLimit() { + let result = GlanceFormatting.middleTruncated("github:create_issue_from_template", limit: 12) + XCTAssertEqual(result.count, 12) + XCTAssertTrue(result.hasPrefix("github"), "kept the server prefix, got \(result)") + XCTAssertTrue(result.hasSuffix("late"), "kept the tool tail, got \(result)") + XCTAssertTrue(result.contains("\u{2026}")) + } + + func testTextExactlyAtTheLimitIsNotTruncated() { + // Pins `>` rather than `>=`: equal length must pass through untouched. + XCTAssertEqual(GlanceFormatting.middleTruncated("abcdef", limit: 6), "abcdef") + } + + func testTruncationToZeroIsEmpty() { + XCTAssertEqual(GlanceFormatting.middleTruncated("abcdef", limit: 0), "") + } + + func testTruncationToOneCharacterIsJustTheEllipsis() { + XCTAssertEqual(GlanceFormatting.middleTruncated("abcdef", limit: 1), "\u{2026}") + } + + // MARK: - Relative time + + func testCompactAgeUnits() { + XCTAssertEqual(GlanceFormatting.compactAge(0), "0s") + XCTAssertEqual(GlanceFormatting.compactAge(12), "12s") + XCTAssertEqual(GlanceFormatting.compactAge(59), "59s") + XCTAssertEqual(GlanceFormatting.compactAge(60), "1m") + XCTAssertEqual(GlanceFormatting.compactAge(3599), "59m") + XCTAssertEqual(GlanceFormatting.compactAge(3600), "1h") + XCTAssertEqual(GlanceFormatting.compactAge(86_400), "1d") + XCTAssertEqual(GlanceFormatting.compactAge(-5), "0s") + } + + func testRelativeTimeParsesFractionalAndPlainTimestamps() { + let fractional = "2027-01-15T08:00:00.123Z" + let plain = "2027-01-15T08:00:00Z" + XCTAssertNotNil(GlanceFormatting.parseTimestamp(fractional)) + XCTAssertNotNil(GlanceFormatting.parseTimestamp(plain)) + + let base = GlanceFormatting.parseTimestamp(plain)! + XCTAssertEqual(GlanceFormatting.relativeTime(plain, now: base.addingTimeInterval(12)), "12s") + XCTAssertEqual(GlanceFormatting.relativeTime(fractional, now: base.addingTimeInterval(180)), "3m") + } + + func testUnparseableTimestampFallsBackToTheRawString() { + XCTAssertEqual(GlanceFormatting.relativeTime("not-a-date"), "not-a-date") + } + + // MARK: - Helpers + + static func entry( + id: String = "a1", + type: String = "tool_call", + server: String? = nil, + tool: String? = nil, + status: String = "success" + ) -> ActivityEntry { + var json: [String: Any] = [ + "id": id, + "type": type, + "status": status, + "timestamp": "2027-01-15T08:00:00Z" + ] + if let server { json["server_name"] = server } + if let tool { json["tool_name"] = tool } + let data = try! JSONSerialization.data(withJSONObject: json) + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(ActivityEntry.self, from: data) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceLinksTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceLinksTests.swift new file mode 100644 index 000000000..6bf1dd44c --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceLinksTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import MCPProxy + +final class GlanceLinksTests: XCTestCase { + + func testSessionAndKeyAreBothAppended() { + XCTAssertEqual( + activityURLString(baseURL: "http://127.0.0.1:8080", apiKey: "k1", sessionID: "sess-42"), + "http://127.0.0.1:8080/ui/activity?session=sess-42&apikey=k1" + ) + } + + func testMissingKeyOmitsTheParameter() { + XCTAssertEqual( + activityURLString(baseURL: "http://127.0.0.1:8080", apiKey: "", sessionID: "sess-42"), + "http://127.0.0.1:8080/ui/activity?session=sess-42" + ) + } + + func testMissingSessionOpensTheUnfilteredLog() { + XCTAssertEqual( + activityURLString(baseURL: "http://127.0.0.1:8080", apiKey: "k1", sessionID: nil), + "http://127.0.0.1:8080/ui/activity?apikey=k1" + ) + XCTAssertEqual( + activityURLString(baseURL: "http://127.0.0.1:8080", apiKey: "", sessionID: ""), + "http://127.0.0.1:8080/ui/activity" + ) + } + + func testSessionIDIsPercentEncoded() { + let url = activityURLString(baseURL: "http://127.0.0.1:8080", apiKey: "", sessionID: "a b&c") + XCTAssertEqual(url, "http://127.0.0.1:8080/ui/activity?session=a%20b%26c") + XCTAssertNotNil(URL(string: url)) + } + + func testNonDefaultPortIsPreserved() { + XCTAssertEqual( + activityURLString(baseURL: "http://127.0.0.1:18080", apiKey: "k", sessionID: "s"), + "http://127.0.0.1:18080/ui/activity?session=s&apikey=k" + ) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceMenuPolicyTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceMenuPolicyTests.swift new file mode 100644 index 000000000..b2f410c62 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceMenuPolicyTests.swift @@ -0,0 +1,105 @@ +import XCTest +import AppKit +@testable import MCPProxy + +/// The exact policy `AppController.rebuildMenu()` applies before it touches the +/// menu: while the status-bar menu is on screen the glance rows are rewritten in +/// place, and anything structural waits for `menuDidClose`. +/// +/// This matters more than an edge case. Task 6 made `MCPSession` Equatable so a +/// client row can show a live call count, which means `glanceSessions` +/// republishes on nearly every 30 s poll for any active session (`lastActivity` +/// moves between polls), and `glanceActivity` republishes on every reconcile. +/// Without this policy the menu would restructure under the cursor during +/// ordinary use. +@MainActor +final class GlanceMenuPolicyTests: XCTestCase { + + private final class ClickStub: NSObject { + @objc func openGlanceRow(_ sender: NSMenuItem) {} + } + + private let clickStub = ClickStub() + + private func makeSection() -> GlanceSection { + GlanceSection(target: clickStub, action: #selector(ClickStub.openGlanceRow(_:))) + } + + /// Row titles carry the relative age, so a later clock is enough to prove + /// whether a row was rewritten. + private static let later = GlanceFormatting.parseTimestamp("2027-01-15T08:05:00Z")! + + func testClosedMenuRebuildsAndLeavesRowsUntouched() { + let state = GlanceFixtures.connectedState() + let section = makeSection() + let rows = section.items(for: state, now: GlanceFixtures.now) + let firstRowTitle = rows[3].title + XCTAssertEqual(firstRowTitle, "github:create_issue — 30s") + + var guardState = MenuRebuildGuard() + XCTAssertEqual(guardState.decide(refreshing: section, from: state, now: Self.later), .rebuild) + XCTAssertEqual(rows[3].title, firstRowTitle, + "a closed menu is rebuilt wholesale — the section must not be touched first") + } + + func testOpenMenuRewritesRowsInPlace() { + let state = GlanceFixtures.connectedState() + let section = makeSection() + let rows = section.items(for: state, now: GlanceFixtures.now) + + var guardState = MenuRebuildGuard() + guardState.menuWillOpen() + XCTAssertEqual(guardState.decide(refreshing: section, from: state, now: Self.later), + .updateInPlace) + XCTAssertEqual(rows[3].title, "github:create_issue — 5m", + "the installed row itself must be rewritten, not a fresh copy of it") + XCTAssertFalse(guardState.isDirty) + } + + func testOpenMenuDefersAStructuralChangeUntilClose() { + let state = GlanceFixtures.connectedState() + let section = makeSection() + let rows = section.items(for: state, now: GlanceFixtures.now) + + let summaryBefore = rows[0].title + XCTAssertEqual(summaryBefore, "12 calls this hour · 1 client") + + var guardState = MenuRebuildGuard() + guardState.menuWillOpen() + + // A third call arrives: the Recent list grows by a row, and the header + // count moves with it. The header is set deliberately: refusing an + // update has to change *nothing*, and a header that alone kept moving + // would describe a set of rows that is not the one below it. + state.callsThisHour = 99 + state.glanceActivity.insert( + GlanceFixtures.entry(id: "c", server: "slack", tool: "post_message", + timestamp: "2027-01-15T07:59:50Z", session: "sess-a"), + at: 0 + ) + + XCTAssertEqual(guardState.decide(refreshing: section, from: state, now: Self.later), + .deferUntilClose) + XCTAssertEqual(rows[3].title, "github:create_issue — 30s", + "a deferred rebuild must leave the on-screen rows exactly as they were") + XCTAssertEqual(rows[0].title, summaryBefore, + "'99 calls' over the old three rows is the half-update the defer exists to prevent") + XCTAssertTrue(guardState.menuDidClose(), "the suppressed rebuild is owed on close") + } + + /// The core going away is structural too — the whole block disappears — so + /// it must not happen under the cursor either. + func testCoreDisconnectWhileOpenIsDeferred() { + let state = GlanceFixtures.connectedState() + let section = makeSection() + _ = section.items(for: state, now: GlanceFixtures.now) + + var guardState = MenuRebuildGuard() + guardState.menuWillOpen() + state.coreState = .idle + + XCTAssertEqual(guardState.decide(refreshing: section, from: state, now: Self.later), + .deferUntilClose) + XCTAssertTrue(guardState.menuDidClose()) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceReconnectGenerationTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceReconnectGenerationTests.swift new file mode 100644 index 000000000..8b15eef4a --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceReconnectGenerationTests.swift @@ -0,0 +1,219 @@ +import XCTest +@testable import MCPProxy + +/// A glance fetch issued to one core must never publish into another. +/// +/// `testLateFetchDoesNotRepopulateAfterDisconnect` pins the easy half of this: +/// a response that resolves while the tray is disconnected is dropped by the +/// `coreState == .connected` guard. The half that guard cannot see is a +/// response that resolves AFTER a reconnect has already restored `.connected` +/// — the predicate is true again, so the previous core's data publishes over +/// the new core's. The refresh loop reconnects in well under the 30s poll +/// interval, so the window is a whole poll cycle wide, not a few milliseconds. +@MainActor +final class GlanceReconnectGenerationTests: XCTestCase { + + /// A source whose fetch takes the core down and brings it back before it + /// answers — the interleaving a `.connected`-only guard cannot detect. + private final class ReconnectingSource: GlanceDataSource { + var state: AppState? + var entries: [ActivityEntry] = [] + var sessions: [APIClient.MCPSession] = [] + var timeline: [UsageBucket] = [] + /// Thrown instead of answering, to cover the catch blocks too. + var failure: Error? + + private func reconnectThenAnswer() throws { + if let state { + state.coreState = .reconnecting(attempt: 1) + state.coreState = .connected + } + if let failure { throw failure } + } + + func usageAggregate(window: String, top: Int) async throws -> UsageAggregateResponse { + try reconnectThenAnswer() + return UsageAggregateResponse(window: "24h", tokenSource: "bytes", tokensSaved: 0, + tokensSavedPercentage: 0, timeline: timeline) + } + func glanceActivity(limit: Int) async throws -> [ActivityEntry] { + try reconnectThenAnswer() + return entries + } + func activeSessions(limit: Int) async throws -> [APIClient.MCPSession] { + try reconnectThenAnswer() + return sessions + } + } + + private func connectedState() -> AppState { + let state = AppState() + state.coreState = .connected + return state + } + + private func source(for state: AppState) -> ReconnectingSource { + let source = ReconnectingSource() + source.state = state + return source + } + + private static func entry(id: String) throws -> ActivityEntry { + let json = """ + {"id":"\(id)","type":"tool_call","status":"success","timestamp":"2026-07-29T11:00:00Z", + "server_name":"srv","tool_name":"t","request_id":"\(id)"} + """ + // swiftlint:disable:next force_unwrapping + return try JSONDecoder().decode(ActivityEntry.self, from: json.data(using: .utf8)!) + } + + private static func session(id: String) throws -> APIClient.MCPSession { + let json = """ + {"id":"\(id)","client_name":"Claude Code","status":"active","tool_call_count":3} + """ + // swiftlint:disable:next force_unwrapping + return try JSONDecoder().decode(APIClient.MCPSession.self, from: json.data(using: .utf8)!) + } + + // MARK: - The generation itself + + func testEachStateChangeStartsANewConnectionGeneration() { + let state = AppState() + let first = state.connectionGeneration + + state.coreState = .connected + + XCTAssertNotEqual(state.connectionGeneration, first) + XCTAssertTrue(state.isCurrentConnection(state.connectionGeneration)) + XCTAssertFalse(state.isCurrentConnection(first)) + } + + /// Re-assigning the same state is not a new connection: the refresh loop + /// would otherwise throw away a perfectly good in-flight response every + /// time something re-published an unchanged `coreState`. + func testRepublishingTheSameStateKeepsTheGeneration() { + let state = connectedState() + let generation = state.connectionGeneration + + state.coreState = .connected + + XCTAssertEqual(state.connectionGeneration, generation) + } + + /// A disconnected tray is nobody's current connection, whatever the number. + func testNoGenerationIsCurrentWhileDisconnected() { + let state = AppState() + XCTAssertFalse(state.isCurrentConnection(state.connectionGeneration)) + } + + // MARK: - The three fetches + + func testAnActivityFetchFromThePreviousCoreDoesNotPublish() async throws { + let state = connectedState() + let source = source(for: state) + source.entries = [try Self.entry(id: "from-the-dead-core")] + + await state.refreshGlanceActivity(from: source) + + XCTAssertTrue(state.glanceActivity.isEmpty, + "the previous core's rows published into the reconnected core") + } + + func testASessionsFetchFromThePreviousCoreDoesNotPublish() async throws { + let state = connectedState() + let source = source(for: state) + source.sessions = [try Self.session(id: "from-the-dead-core")] + + await state.refreshGlanceSessions(from: source) + + XCTAssertTrue(state.glanceSessions.isEmpty, + "the previous core's clients published into the reconnected core") + } + + func testAUsageFetchFromThePreviousCoreDoesNotPublish() async { + let state = connectedState() + let source = source(for: state) + source.timeline = [UsageBucket(start: Date(), calls: 99, errors: 0, totalRespBytes: 0)] + + await state.refreshUsage(from: source) + + XCTAssertNil(state.usageTimeline, + "the previous core's usage published into the reconnected core") + XCTAssertNil(state.callsThisHour) + } + + /// The failure paths need the same guard: a dead core's error recorded + /// against the new connection would mark a healthy core as failing, and the + /// streak is what decides whether the block admits it is stale. + func testAFailureFromThePreviousCoreIsNotRecorded() async { + let state = connectedState() + let source = source(for: state) + source.failure = APIClientError.notReady + + await state.refreshGlanceActivity(from: source) + await state.refreshGlanceSessions(from: source) + await state.refreshUsage(from: source) + + XCTAssertNil(state.glanceError) + XCTAssertTrue(state.glanceFailureStreak.isEmpty) + XCTAssertNil(state.usageError) + } + + // MARK: - The SSE path + + /// An SSE row belongs to the stream that delivered it, and that stream + /// belongs to one connection. The publish happens a MainActor hop after the + /// event is read, and executors guarantee no ordering across that hop + /// against the reconnect work — so an event from the previous core can + /// resume after `.connected` has been restored and prepend a dead core's + /// call to the new core's feed. + /// + /// The disconnected case was already covered; this is the one that needs a + /// generation, because `.connected` is true again by the time it publishes. + func testAnSSERowFromThePreviousCoreDoesNotPublish() throws { + let state = connectedState() + let generation = state.connectionGeneration + + // The core dies and comes back while the event is in flight. + state.coreState = .reconnecting(attempt: 1) + state.coreState = .connected + + state.prependGlanceActivity(try Self.entry(id: "from-the-dead-core"), + generation: generation) + + XCTAssertTrue(state.glanceActivity.isEmpty, + "the previous core's SSE row published into the reconnected core") + } + + /// Positive control for the same call: an event from the live connection + /// still reaches the feed. + /// + /// Note what this pair does NOT cover, which is why `SSEStreamSessionTests` + /// exists: both call the publish helper directly with a generation the test + /// chose, so they pin the guard and say nothing about where production reads + /// that generation. They pass unchanged against the version that reads it + /// when the event arrives — the regression the guard was written for. + func testAnSSERowFromTheLiveConnectionPublishes() throws { + let state = connectedState() + + state.prependGlanceActivity(try Self.entry(id: "live"), + generation: state.connectionGeneration) + + XCTAssertEqual(state.glanceActivity.map(\.id), ["live"]) + } + + /// Positive control: with no reconnection the very same fetches publish, so + /// the tests above are pinning the reconnect, not a broken refresh path. + func testTheSameFetchesPublishWhenTheConnectionHolds() async throws { + let state = connectedState() + let source = ReconnectingSource() // no AppState, so it never reconnects + source.entries = [try Self.entry(id: "a1")] + source.sessions = [try Self.session(id: "s1")] + + await state.refreshGlanceActivity(from: source) + await state.refreshGlanceSessions(from: source) + + XCTAssertEqual(state.glanceActivity.map(\.id), ["a1"]) + XCTAssertEqual(state.glanceSessions.map(\.id), ["s1"]) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceRefreshFailureTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceRefreshFailureTests.swift new file mode 100644 index 000000000..b1224bdf3 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceRefreshFailureTests.swift @@ -0,0 +1,257 @@ +import XCTest +import Combine +@testable import MCPProxy + +/// The activity and sessions refreshes, on the failure path. +/// +/// Both used to fetch through the concrete `APIClient` inside +/// `CoreProcessManager` and swallow their errors with a bare +/// `// Non-fatal` — so a permanently failing fetch rendered "No tool calls yet" +/// and "No connected clients", which is exactly what a genuinely idle proxy +/// renders. `usageError` exists to stop the third feed telling that same quiet +/// lie; these tests exist so the other two cannot tell it either. +@MainActor +final class GlanceRefreshFailureTests: XCTestCase { + + /// A data source that can fail per feed. + private final class StubSource: GlanceDataSource { + var activityError: Error? + var sessionsError: Error? + var entries: [ActivityEntry] = [] + var sessions: [APIClient.MCPSession] = [] + + func usageAggregate(window: String, top: Int) async throws -> UsageAggregateResponse { + UsageAggregateResponse(window: "24h", tokenSource: "bytes", tokensSaved: 0, + tokensSavedPercentage: 0, timeline: []) + } + func glanceActivity(limit: Int) async throws -> [ActivityEntry] { + if let activityError { throw activityError } + return entries + } + func activeSessions(limit: Int) async throws -> [APIClient.MCPSession] { + if let sessionsError { throw sessionsError } + return sessions + } + } + + private func connectedState() -> AppState { + let state = AppState() + state.coreState = .connected + return state + } + + private static func entry(id: String) throws -> ActivityEntry { + let json = """ + {"id":"\(id)","type":"tool_call","status":"success","timestamp":"2026-07-29T11:00:00Z", + "server_name":"srv","tool_name":"t","request_id":"\(id)"} + """ + // swiftlint:disable:next force_unwrapping + return try JSONDecoder().decode(ActivityEntry.self, from: json.data(using: .utf8)!) + } + + private static func session(id: String) throws -> APIClient.MCPSession { + let json = """ + {"id":"\(id)","client_name":"Claude Code","status":"active","tool_call_count":3} + """ + // swiftlint:disable:next force_unwrapping + return try JSONDecoder().decode(APIClient.MCPSession.self, from: json.data(using: .utf8)!) + } + + // MARK: - Failures reach state + + func testAFailedActivityRefreshIsRecorded() async { + let state = connectedState() + let source = StubSource() + source.activityError = APIClientError.httpError(statusCode: 503, message: "core restarting") + + await state.refreshGlanceActivity(from: source) + + XCTAssertEqual(state.glanceFailureStreak[.activity], 1) + XCTAssertEqual(state.glanceError, "HTTP 503: core restarting") + } + + func testAFailedSessionsRefreshIsRecorded() async { + let state = connectedState() + let source = StubSource() + source.sessionsError = APIClientError.notReady + + await state.refreshGlanceSessions(from: source) + + XCTAssertEqual(state.glanceFailureStreak[.sessions], 1) + XCTAssertNotNil(state.glanceError) + } + + /// Consecutive failures accumulate — one blip and a core that has been gone + /// for minutes must be distinguishable, which is what the streak is for. + func testConsecutiveFailuresAccumulate() async { + let state = connectedState() + let source = StubSource() + source.activityError = APIClientError.noData + + await state.refreshGlanceActivity(from: source) + await state.refreshGlanceActivity(from: source) + await state.refreshGlanceActivity(from: source) + + XCTAssertEqual(state.glanceFailureStreak[.activity], 3) + } + + /// …and a success resets that feed's streak, so a recovered core stops + /// being reported as failing. + func testASuccessfulRefreshResetsTheStreak() async throws { + let state = connectedState() + let source = StubSource() + source.activityError = APIClientError.noData + await state.refreshGlanceActivity(from: source) + XCTAssertEqual(state.glanceFailureStreak[.activity], 1) + + source.activityError = nil + source.entries = [try Self.entry(id: "a1")] + await state.refreshGlanceActivity(from: source) + + XCTAssertEqual(state.glanceFailureStreak[.activity], 0) + XCTAssertNil(state.glanceError) + XCTAssertEqual(state.glanceActivity.map(\.id), ["a1"]) + } + + /// One feed's success must not clear another feed's failure: they fail + /// independently, and a per-feed streak that any success reset would report + /// a persistently failing feed as healthy every 30 seconds. + func testOneFeedsSuccessDoesNotResetAnothersStreak() async throws { + let state = connectedState() + let source = StubSource() + source.activityError = APIClientError.noData + source.sessions = [try Self.session(id: "s1")] + + await state.refreshGlanceActivity(from: source) + await state.refreshGlanceSessions(from: source) + + XCTAssertEqual(state.glanceFailureStreak[.activity], 1) + XCTAssertEqual(state.glanceFailureStreak[.sessions], 0) + } + + /// The same guard the three publish helpers carry: a fetch already past its + /// `guard let apiClient` when the core goes away resolves into its catch + /// block after `clearGlanceState()`, and a dead core's failure must not + /// outlive it. + func testAFailureIsIgnoredWhileDisconnected() async { + let state = AppState() + let source = StubSource() + source.activityError = APIClientError.noData + + await state.refreshGlanceActivity(from: source) + + XCTAssertNil(state.glanceError) + XCTAssertNil(state.glanceFailureStreak[.activity]) + } + + /// Disconnecting clears the recorded failure along with the feeds it + /// describes — the next core starts from "loading", not from the previous + /// core's error. + func testClearGlanceStateClearsTheFailureRecord() async { + let state = connectedState() + let source = StubSource() + source.activityError = APIClientError.noData + await state.refreshGlanceActivity(from: source) + + state.coreState = .idle + + XCTAssertNil(state.glanceError) + XCTAssertTrue(state.glanceFailureStreak.isEmpty) + } + + // MARK: - Persistent failure becomes visible + + /// One blip must not accuse the core of being dead — the refresh retries in + /// 30 seconds and a single failed fetch is normal during a restart. Only a + /// failure that keeps happening earns the marker. + func testASingleFailureDoesNotMarkTheBlockStale() async { + let state = connectedState() + let source = StubSource() + source.activityError = APIClientError.noData + + await state.refreshGlanceActivity(from: source) + + XCTAssertFalse(state.glanceStale) + } + + /// …but a failure that keeps happening does. Without this the block renders + /// a dead core's last five calls as a live, ticking display: `refreshState` + /// bumps `activityVersion` unconditionally, so the menu keeps rebuilding + /// with a fresh clock even when every fetch in the cycle failed. + func testThePersistentFailureMarksTheBlockStale() async { + let state = connectedState() + let source = StubSource() + source.activityError = APIClientError.noData + + for _ in 0..:"`); the 30-second reconciling poll replaces it + /// with the storage-assigned ULID for the very same call. Keyed on `id`, + /// every poll would look like a wholesale turnover of all five rows. + func testReconcileIdTurnoverIsNotARecordTurnover() { + let state = Self.busyState() + let section = Self.makeSection() + let items = section.items(for: state, now: Self.now) + let iconBefore = items[3].image + + state.glanceActivity = [ + Self.entry(id: "01JQ8Z0000000000000000001", server: "github", tool: "create_issue", + timestamp: "2027-01-15T07:59:30Z", session: "sess-a", request: "req-a"), + Self.entry(id: "01JQ8Z0000000000000000002", server: "jira", tool: "get_issue", status: "error", + error: "auth failed: token expired. retry after refresh", + timestamp: "2027-01-15T07:58:00Z", session: "sess-b", request: "req-b") + ] + + XCTAssertTrue(section.updateInPlace(for: state, now: Self.now)) + XCTAssertTrue(items[3].image === iconBefore, + "same request id means the same record, so the row's icon must be left alone") + XCTAssertEqual(items[3].title, "github:create_issue — 30s") + XCTAssertEqual(items[3].representedObject as? String, "sess-a") + } + + func testDifferentRecordInTheSameSlotRewritesTheIcon() { + let state = Self.busyState() + let section = Self.makeSection() + let items = section.items(for: state, now: Self.now) + let iconBefore = items[3].image + let previousFailure = state.glanceActivity[1] + + state.glanceActivity = [ + Self.entry(id: "c", server: "obsidian", tool: "search_notes", + timestamp: "2027-01-15T07:59:55Z", session: "sess-c"), + previousFailure + ] + + XCTAssertTrue(section.updateInPlace(for: state, now: Self.now)) + XCTAssertFalse(items[3].image === iconBefore, + "a different record must rewrite the row's entire identity, icon included") + XCTAssertEqual(items[3].representedObject as? String, "sess-c") + } + + /// "Same record" must not mean "skip the update": the final status arrives + /// on a record whose request id has not changed — the reason + /// `AppState.updateGlanceActivity` fingerprints status rather than ids. + func testSameRecordStillPicksUpALateStatusCorrection() { + let state = Self.busyState() + let section = Self.makeSection() + let items = section.items(for: state, now: Self.now) + let previousFailure = state.glanceActivity[1] + + state.glanceActivity = [ + Self.entry(id: "a", server: "github", tool: "create_issue", status: "error", + error: "rate limited: try later", + timestamp: "2027-01-15T07:59:30Z", session: "sess-a"), + previousFailure + ] + + XCTAssertTrue(section.updateInPlace(for: state, now: Self.now)) + XCTAssertEqual(items[3].title, "github:create_issue · rate limited — 30s") + XCTAssertEqual(items[3].image?.accessibilityDescription, "failed") + XCTAssertEqual(items[3].toolTip, "github:create_issue\nrate limited: try later") + } + + // MARK: - Client rows are not rewritten when nothing changed + + /// `updateInPlace` fires on nearly every 30s poll for a busy proxy — and + /// under an open menu, where each write is a re-layout. A poll that returns + /// the same session byte for byte must therefore write nothing at all, and + /// in particular must not allocate a fresh tinted icon: the connected dot is + /// a constant. + func testIdenticalClientPollWritesNothing() { + let state = Self.busyState() + let section = Self.makeSection() + let row = section.items(for: state, now: Self.now)[8] + let dotBefore = row.image + + var titleWrites = 0 + let observation = row.observe(\.title, options: [.new]) { _, _ in titleWrites += 1 } + state.glanceSessions = [ + Self.session(id: "sess-a", name: "Claude Code", version: "2.1.0", + calls: 8, lastActivity: "2027-01-15T07:59:00Z") + ] + + XCTAssertTrue(section.updateInPlace(for: state, now: Self.now)) + observation.invalidate() + + XCTAssertTrue(row.image === dotBefore, + "the connected dot is a constant — an identical poll must not allocate a new one") + XCTAssertEqual(titleWrites, 0, + "an identical poll must not rewrite the title of a row in an open menu") + XCTAssertEqual(row.title, "Claude Code — 8 calls · 1m") + } + + /// …and the guards must not freeze the row: the live call count is the whole + /// reason `MCPSession` became `Equatable`. + func testChangedClientCallCountStillRewritesTheRow() { + let state = Self.busyState() + let section = Self.makeSection() + let row = section.items(for: state, now: Self.now)[8] + + state.glanceSessions = [ + Self.session(id: "sess-a", name: "Claude Code", version: "2.1.0", + calls: 40, lastActivity: "2027-01-15T07:59:00Z") + ] + + XCTAssertTrue(section.updateInPlace(for: state, now: Self.now)) + XCTAssertEqual(row.title, "Claude Code — 40 calls · 1m") + XCTAssertEqual(row.accessibilityLabel(), "Claude Code, 40 calls, last active 1m ago") + } + + // MARK: - Status is carried by shape AND colour + + func testStatusIsEncodedByShapeAndColourNotColourAlone() { + let stamp = "2027-01-15T07:59:30Z" + let succeeded = Self.entry(id: "s", server: "a", tool: "t", timestamp: stamp) + let failed = Self.entry(id: "f", server: "a", tool: "t", status: "error", timestamp: stamp) + let pending = Self.entry(id: "p", server: "a", tool: "t", status: "running", timestamp: stamp) + + let shapes = [succeeded, failed, pending].map(GlanceFormatting.statusSymbolName(for:)) + XCTAssertEqual(Set(shapes).count, 3, "shape alone must separate the three outcomes") + + XCTAssertNotEqual(GlanceSection.statusTint(for: succeeded), GlanceSection.statusTint(for: failed)) + XCTAssertNotEqual(GlanceSection.statusTint(for: failed), GlanceSection.statusTint(for: pending)) + XCTAssertNotEqual(GlanceSection.statusTint(for: succeeded), GlanceSection.statusTint(for: pending)) + + XCTAssertEqual(GlanceSection.outcomeDescription(for: pending), "in progress", + "a call still running must not be announced as failed") + } + + func testStatusIconKeepsItsTintInTheMenu() { + let section = Self.makeSection() + let items = section.items(for: Self.busyState(), now: Self.now) + XCTAssertEqual(items[3].image?.isTemplate, false, + "a template image is recoloured by the menu, which would drop the status tint") + XCTAssertEqual(items[4].image?.isTemplate, false) + } + + // MARK: - Helpers + + private final class ClickStub: NSObject { + @objc func openGlanceRow(_ sender: NSMenuItem) {} + } + + private static let clickStub = ClickStub() + + private static func makeSection() -> GlanceSection { + GlanceSection(target: clickStub, action: #selector(ClickStub.openGlanceRow(_:))) + } + + /// A connected core with two qualifying calls and one active client. + private static func busyState() -> AppState { + let state = AppState() + // coreState first: its didSet clears the glance feeds on any non-connected state. + state.coreState = .connected + state.callsThisHour = 12 + state.glanceActivity = [ + entry(id: "a", server: "github", tool: "create_issue", + timestamp: "2027-01-15T07:59:30Z", session: "sess-a"), + entry(id: "b", server: "jira", tool: "get_issue", status: "error", + error: "auth failed: token expired. retry after refresh", + timestamp: "2027-01-15T07:58:00Z", session: "sess-b") + ] + state.glanceSessions = [ + session(id: "sess-a", name: "Claude Code", version: "2.1.0", + calls: 8, lastActivity: "2027-01-15T07:59:00Z") + ] + return state + } + + private static func entry( + id: String, + type: String = "tool_call", + server: String? = nil, + tool: String? = nil, + status: String = "success", + error: String? = nil, + timestamp: String, + session: String? = nil, + request: String? = nil + ) -> ActivityEntry { + var json: [String: Any] = [ + "id": id, + "type": type, + "status": status, + "timestamp": timestamp, + // Defaults to a request id derived from `id`; pass `request:` + // explicitly to model the reconcile, which re-ids the same record. + "request_id": request ?? "req-\(id)" + ] + if let server { json["server_name"] = server } + if let tool { json["tool_name"] = tool } + if let error { json["error_message"] = error } + if let session { json["session_id"] = session } + let data = try! JSONSerialization.data(withJSONObject: json) + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(ActivityEntry.self, from: data) + } + + private static func session( + id: String, + name: String, + version: String, + calls: Int, + lastActivity: String + ) -> APIClient.MCPSession { + let json: [String: Any] = [ + "id": id, + "status": "active", + "client_name": name, + "client_version": version, + "tool_call_count": calls, + "last_activity": lastActivity + ] + let data = try! JSONSerialization.data(withJSONObject: json) + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(APIClient.MCPSession.self, from: data) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift new file mode 100644 index 000000000..b9e85e6c1 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionCollapseTests.swift @@ -0,0 +1,176 @@ +import XCTest +@testable import MCPProxy + +final class GlanceSelectionCollapseTests: XCTestCase { + + // MARK: - Rule 4 + + func testPairedFailureCollapsesToTheUpstreamRecord() { + let entries = [ + GlanceSelectionTests.entry(id: "wrapper", type: "internal_tool_call", tool: "call_tool_read", + status: "error", requestId: "req-1"), + GlanceSelectionTests.entry(id: "upstream", type: "tool_call", server: "jira", tool: "get_issue", + status: "error", requestId: "req-1") + ] + let rows = GlanceSelection.activityRows(from: entries) + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows[0].id, "upstream") + XCTAssertEqual(rows[0].serverName, "jira") + } + + func testPreDispatchWrapperFailureWithNoPairStillRenders() { + let entries = [ + GlanceSelectionTests.entry(id: "wrapper", type: "internal_tool_call", tool: "call_tool_read", + status: "error", requestId: "req-2") + ] + let rows = GlanceSelection.activityRows(from: entries) + XCTAssertEqual(rows.map(\.id), ["wrapper"]) + } + + func testRecordsWithoutRequestIDsAreNeverCollapsed() { + let entries = [ + GlanceSelectionTests.entry(id: "a", type: "tool_call", server: "s", tool: "t"), + GlanceSelectionTests.entry(id: "b", type: "tool_call", server: "s", tool: "t") + ] + XCTAssertEqual(GlanceSelection.activityRows(from: entries).map(\.id), ["a", "b"]) + } + + func testCollapsedRowKeepsTheGroupsRecencyPosition() { + let entries = [ + GlanceSelectionTests.entry(id: "newest", type: "tool_call", server: "a", tool: "t", requestId: "r-9"), + GlanceSelectionTests.entry(id: "wrapper", type: "internal_tool_call", tool: "call_tool_read", + status: "error", requestId: "r-8"), + GlanceSelectionTests.entry(id: "upstream", type: "tool_call", server: "b", tool: "t", + status: "error", requestId: "r-8") + ] + XCTAssertEqual(GlanceSelection.activityRows(from: entries).map(\.id), ["newest", "upstream"]) + } + + // MARK: - Capping over a realistic page + + func testFiveRowsAreSelectedFromAFiftyRecordPageFullOfNoise() { + var page: [ActivityEntry] = [] + // 40 management-built-in calls arrive first — rule 1 must drop them all. + for i in 0..<40 { + page.append(GlanceSelectionTests.entry( + id: "mgmt-\(i)", type: "internal_tool_call", + tool: i.isMultiple(of: 2) ? "upstream_servers" : "quarantine_security")) + } + // 4 successful wrappers — rule 3 drops them. + for i in 0..<4 { + page.append(GlanceSelectionTests.entry( + id: "wrap-\(i)", type: "internal_tool_call", tool: "call_tool_read")) + } + // 6 real calls — only the first five become rows. + for i in 0..<6 { + page.append(GlanceSelectionTests.entry( + id: "call-\(i)", type: "tool_call", server: "srv", tool: "tool\(i)")) + } + XCTAssertEqual(page.count, 50) + + let rows = GlanceSelection.activityRows(from: page) + XCTAssertEqual(rows.map(\.id), ["call-0", "call-1", "call-2", "call-3", "call-4"]) + } + + /// Depth, not just filtering. The client requests ONE page and applies + /// rules 1-3 afterwards, so the rows the user sees are only as deep as that + /// page: a burst of proxy-management calls at the head of the log pushes + /// real calls off it, and the menu says "No tool calls yet" while the calls + /// sit just below the fold. + /// + /// 90 management calls is not a contrived number — `upstream_servers` and + /// `quarantine_security` are unconditionally dropped by rule 1, and an + /// agent walking a server list makes them in bursts. + func testFiveRowsSurviveABurstOfManagementCallsAtTheHeadOfTheLog() { + var log: [ActivityEntry] = [] // newest first, as the endpoint returns it + for i in 0..<90 { + log.append(GlanceSelectionTests.entry( + id: "mgmt-\(i)", type: "internal_tool_call", + tool: i.isMultiple(of: 2) ? "upstream_servers" : "quarantine_security")) + } + for i in 0..<6 { + log.append(GlanceSelectionTests.entry( + id: "call-\(i)", type: "tool_call", server: "srv", tool: "tool\(i)")) + } + + let page = Array(log.prefix(AppState.glanceActivityPageSize)) + let rows = GlanceSelection.activityRows(from: page) + + XCTAssertEqual(rows.map(\.id), ["call-0", "call-1", "call-2", "call-3", "call-4"]) + } + + /// Five qualifying RECORDS are not five rows. A failed call emits a wrapper + /// and an upstream record under one request id, and rule 4 collapses the + /// pair — so the depth the rows need is measured in request groups, which is + /// what the guarantee has to be stated in. The depth tests above use unique + /// request ids and would never notice. + func testFiveQualifyingRecordsSharingRequestIDsProduceFewerRows() { + var page: [ActivityEntry] = [] + for i in 0..<3 { + page.append(GlanceSelectionTests.entry( + id: "wrapper-\(i)", type: "internal_tool_call", tool: "call_tool_read", + status: "error", requestId: "req-\(i)")) + page.append(GlanceSelectionTests.entry( + id: "upstream-\(i)", type: "tool_call", server: "srv", tool: "tool\(i)", + status: "error", requestId: "req-\(i)")) + } + + let rows = GlanceSelection.activityRows(from: page) + + XCTAssertEqual(page.count, 6, "six qualifying records…") + XCTAssertEqual(rows.map(\.id), ["upstream-0", "upstream-1", "upstream-2"], + "…but three request groups, so three rows") + } + + /// The honest residual, pinned so nobody has to rediscover it: the feed is + /// exactly one page deep. Noise deeper than the page hides real calls, and + /// the endpoint clamps `limit` at 100, so this is the floor of what a single + /// request can promise — paging past it is possible (`offset` works + /// end-to-end) but each request re-walks the whole activity bucket, which + /// is the expensive part. + func testRowsGoNoDeeperThanOnePage() { + var log: [ActivityEntry] = [] + for i in 0..<(AppState.glanceActivityPageSize + 1) { + log.append(GlanceSelectionTests.entry( + id: "mgmt-\(i)", type: "internal_tool_call", tool: "upstream_servers")) + } + log.append(GlanceSelectionTests.entry(id: "call-0", type: "tool_call", server: "srv", tool: "t")) + + let page = Array(log.prefix(AppState.glanceActivityPageSize)) + + XCTAssertTrue(GlanceSelection.activityRows(from: page).isEmpty, + "a call below the page is not shown; that is the documented limit") + } + + func testFewerThanFiveQualifyingRecordsYieldsWhatThereIs() { + let rows = GlanceSelection.activityRows(from: [ + GlanceSelectionTests.entry(id: "call-0", type: "tool_call", server: "srv", tool: "t") + ]) + XCTAssertEqual(rows.count, 1) + } + + func testEmptyInputYieldsNoRows() { + XCTAssertTrue(GlanceSelection.activityRows(from: []).isEmpty) + } + + // MARK: - Clients + + func testActiveClientsFiltersClosedSessionsAndCapsAtFive() { + var sessions = [GlanceSelectionTests.session(id: "closed-1", status: "closed")] + for i in 0..<7 { + sessions.append(GlanceSelectionTests.session(id: "active-\(i)", status: "active")) + } + sessions.append(GlanceSelectionTests.session(id: "closed-2", status: "closed")) + + let clients = GlanceSelection.activeClients(from: sessions) + XCTAssertEqual(clients.map(\.id), ["active-0", "active-1", "active-2", "active-3", "active-4"]) + } + + func testActiveClientsIsEmptyWhenEverySessionIsClosed() { + let sessions = [ + GlanceSelectionTests.session(id: "a", status: "closed"), + GlanceSelectionTests.session(id: "b", status: "closed") + ] + XCTAssertTrue(GlanceSelection.activeClients(from: sessions).isEmpty) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionTests.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionTests.swift new file mode 100644 index 000000000..0900f514b --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceSelectionTests.swift @@ -0,0 +1,83 @@ +import XCTest +@testable import MCPProxy + +final class GlanceSelectionTests: XCTestCase { + + // MARK: - Rule 2 + + func testUpstreamToolCallIsAlwaysIncluded() { + let entry = Self.entry(id: "1", type: "tool_call", server: "github", tool: "create_issue") + XCTAssertTrue(GlanceSelection.qualifies(entry)) + } + + // MARK: - Rule 3 + + func testDiscoveryAndExecutionBuiltInsAreIncluded() { + for tool in ["retrieve_tools", "code_execution", "describe_tool"] { + let entry = Self.entry(id: tool, type: "internal_tool_call", tool: tool) + XCTAssertTrue(GlanceSelection.qualifies(entry), "\(tool) should qualify") + } + } + + func testSuccessfulCallToolWrapperIsExcluded() { + let entry = Self.entry(id: "w", type: "internal_tool_call", tool: "call_tool_write") + XCTAssertFalse(GlanceSelection.qualifies(entry)) + } + + func testFailedCallToolWrapperIsIncluded() { + let entry = Self.entry(id: "w", type: "internal_tool_call", tool: "call_tool_write", status: "error") + XCTAssertTrue(GlanceSelection.qualifies(entry), "pre-dispatch failures have no upstream record") + } + + // MARK: - Rule 1 beats rule 3 + + func testManagementBuiltInsAreExcludedEvenWhenTheyFail() { + for tool in ["upstream_servers", "quarantine_security"] { + let ok = Self.entry(id: "\(tool)-ok", type: "internal_tool_call", tool: tool) + let bad = Self.entry(id: "\(tool)-bad", type: "internal_tool_call", tool: tool, status: "error") + XCTAssertFalse(GlanceSelection.qualifies(ok), "\(tool) success must be excluded") + XCTAssertFalse(GlanceSelection.qualifies(bad), "\(tool) failure must be excluded (rule 1 beats rule 3)") + } + } + + func testNonActivityTypesAreExcluded() { + XCTAssertFalse(GlanceSelection.qualifies(Self.entry(id: "s", type: "security_scan"))) + XCTAssertFalse(GlanceSelection.qualifies(Self.entry(id: "o", type: "oauth_event", status: "error"))) + } + + // MARK: - Helpers + + static func entry( + id: String, + type: String, + server: String? = nil, + tool: String? = nil, + status: String = "success", + requestId: String? = nil + ) -> ActivityEntry { + var json: [String: Any] = [ + "id": id, + "type": type, + "status": status, + "timestamp": "2027-01-15T08:00:00Z" + ] + if let server { json["server_name"] = server } + if let tool { json["tool_name"] = tool } + if let requestId { json["request_id"] = requestId } + let data = try! JSONSerialization.data(withJSONObject: json) + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(ActivityEntry.self, from: data) + } + + static func session(id: String, status: String, clientName: String = "Claude Code") -> APIClient.MCPSession { + let json: [String: Any] = [ + "id": id, + "status": status, + "client_name": clientName, + "tool_call_count": 3 + ] + let data = try! JSONSerialization.data(withJSONObject: json) + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(APIClient.MCPSession.self, from: data) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/GlanceStubURLProtocol.swift b/native/macos/MCPProxy/MCPProxyTests/GlanceStubURLProtocol.swift new file mode 100644 index 000000000..4b268f5db --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/GlanceStubURLProtocol.swift @@ -0,0 +1,63 @@ +// GlanceStubURLProtocol.swift +// MCPProxyTests +// +// A URLProtocol that records every request URL and replays a canned JSON body, +// so APIClient's request building and decoding can be tested without a core. + +import Foundation +@testable import MCPProxy + +final class GlanceStubURLProtocol: URLProtocol { + + /// Absolute URL strings seen by the stub, in request order. + static var requestedURLs: [String] = [] + + /// Body replayed for every request. + static var responseBody = Data() + + /// Status code replayed for every request. + static var statusCode = 200 + + static func reset() { + requestedURLs = [] + responseBody = Data() + statusCode = 200 + } + + /// An APIClient whose traffic is intercepted by this stub. + static func makeClient() -> APIClient { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GlanceStubURLProtocol.self] + return APIClient( + session: URLSession(configuration: config), + baseURL: "http://127.0.0.1:8080", + apiKey: nil + ) + } + + /// Wrap a payload in the standard `{"success":true,"data":…}` envelope. + static func envelope(_ json: String) -> Data { + Data("{\"success\":true,\"data\":\(json)}".utf8) + } + + override class func canInit(with request: URLRequest) -> Bool { true } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + if let url = request.url { + GlanceStubURLProtocol.requestedURLs.append(url.absoluteString) + } + let response = HTTPURLResponse( + url: request.url ?? URL(string: "http://127.0.0.1:8080")!, + statusCode: GlanceStubURLProtocol.statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: GlanceStubURLProtocol.responseBody) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} diff --git a/native/macos/MCPProxy/MCPProxyTests/MenuRebuildGuardTests.swift b/native/macos/MCPProxy/MCPProxyTests/MenuRebuildGuardTests.swift new file mode 100644 index 000000000..e6984c7cc --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/MenuRebuildGuardTests.swift @@ -0,0 +1,51 @@ +import XCTest +import AppKit +@testable import MCPProxy + +final class MenuRebuildGuardTests: XCTestCase { + + func testClosedMenuAlwaysRebuilds() { + var guardState = MenuRebuildGuard() + XCTAssertEqual(guardState.decide(structureChanged: false), .rebuild) + XCTAssertEqual(guardState.decide(structureChanged: true), .rebuild) + XCTAssertFalse(guardState.isDirty) + } + + func testOpenMenuWithSameStructureUpdatesInPlace() { + var guardState = MenuRebuildGuard() + guardState.menuWillOpen() + XCTAssertEqual(guardState.decide(structureChanged: false), .updateInPlace) + XCTAssertEqual(guardState.decide(structureChanged: false), .updateInPlace) + XCTAssertFalse(guardState.isDirty, "In-place updates must not owe a rebuild") + XCTAssertFalse(guardState.menuDidClose(), "No rebuild is owed after in-place updates only") + } + + func testStructuralChangeWhileOpenIsDeferredAndRunsOnceOnClose() { + var guardState = MenuRebuildGuard() + guardState.menuWillOpen() + XCTAssertEqual(guardState.decide(structureChanged: true), .deferUntilClose) + XCTAssertEqual(guardState.decide(structureChanged: true), .deferUntilClose) + XCTAssertTrue(guardState.isDirty) + + XCTAssertTrue(guardState.menuDidClose(), "The deferred rebuild must run on close") + XCTAssertFalse(guardState.isMenuOpen) + XCTAssertFalse(guardState.menuDidClose(), "The deferred rebuild runs exactly once") + } + + /// The stale flag can only come from a `menuDidClose` that never arrived — + /// after a close that did arrive the flag is already clear, so reopening + /// through the normal path proves nothing. Model the dropped close. + func testReopeningClearsAStaleDirtyFlag() { + var guardState = MenuRebuildGuard() + guardState.menuWillOpen() + _ = guardState.decide(structureChanged: true) + XCTAssertTrue(guardState.isDirty) + + // No menuDidClose in between. + guardState.menuWillOpen() + XCTAssertFalse(guardState.isDirty, + "a dirty flag carried into a fresh open would defer a rebuild that is no longer owed") + XCTAssertFalse(guardState.menuDidClose(), + "and would then fire that stale rebuild on the next close") + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/MenuRefreshSchedulerTests.swift b/native/macos/MCPProxy/MCPProxyTests/MenuRefreshSchedulerTests.swift new file mode 100644 index 000000000..a94ff9923 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/MenuRefreshSchedulerTests.swift @@ -0,0 +1,190 @@ +import XCTest +import AppKit +import Combine +@testable import MCPProxy + +/// The delivery layer underneath the glance's open-menu update path. +/// +/// `GlanceSection.updateInPlace` and `MenuRebuildGuard`'s update-in-place / +/// defer-until-close branches are covered elsewhere, but every one of those +/// tests calls the section directly. In production nothing calls it while the +/// menu is on screen except the debounced `objectWillChange` sink — so if that +/// sink is not serviced during menu tracking, all of that machinery is dead code +/// and the glance is a snapshot frozen at menu-open time. That is not +/// hypothetical: it shipped that way, and live QA held the menu open for 45s and +/// saw a 45-second-old call still captioned "3s", counts that never moved, two +/// screenshots 2s apart pixel-identical, and a call made while the menu was open +/// never appear. +/// +/// While an `NSMenu` tracks, the main run loop runs in +/// `NSEventTrackingRunLoopMode`. These tests therefore pump the main run loop in +/// **that mode only** — never `.default` — which is what lets them tell a +/// scheduler that survives tracking from one that does not. +@MainActor +final class MenuRefreshSchedulerTests: XCTestCase { + + /// Mirrors the production debounce interval at the subscription site. + private static let debounceMs = 500 + + /// `.eventTracking` only reaches the main dispatch queue once it is one of + /// the run loop's *common* modes, and it is AppKit that puts it there when + /// `NSApplication` is initialized. The tray is a real `NSApplication`, so + /// that holds in production; an xctest bundle has to ask for it, and without + /// this every test below would "pass" by measuring a process where no + /// scheduler at all is serviced during tracking. + private func requireEventTrackingIsACommonMode() { + _ = NSApplication.shared + } + + // MARK: - The regression + + /// The end-to-end pin: a state change while the menu is "tracking" must + /// reach the rows, not be dropped. + /// + /// The sink body mirrors `AppController.rebuildMenu`'s guard call rather + /// than invoking it (that method is private and needs a live + /// `NSStatusItem`). What is *not* mirrored is the scheduler: this binds to + /// `AppController.menuRefreshScheduler`, the same value the subscription + /// uses, so reverting that to `RunLoop.main` fails this test. + func testAStateChangeWhileTheMenuIsTrackingRewritesTheRowsInPlace() { + requireEventTrackingIsACommonMode() + + let state = GlanceFixtures.connectedState() + let section = Self.makeSection() + let items = section.items(for: state, now: Self.now) + let header = items[0] + XCTAssertEqual(header.title, "12 calls this hour · 1 client") + + var rebuildGuard = MenuRebuildGuard() + rebuildGuard.menuWillOpen() + + var decisions: [MenuRebuildDecision] = [] + let token = state.objectWillChange + .debounce(for: .milliseconds(Self.debounceMs), + scheduler: AppController.menuRefreshScheduler) + .sink { _ in + MainActor.assumeIsolated { + decisions.append(rebuildGuard.decide(refreshing: section, + from: state, + now: Self.now)) + } + } + defer { token.cancel() } + + // A call lands while the user is reading the menu. + state.callsThisHour = 13 + + Self.pump(.eventTracking, until: { !decisions.isEmpty }) + + XCTAssertEqual(decisions, [.updateInPlace], + "a refresh during menu tracking must reach the section and take the in-place branch") + XCTAssertEqual(header.title, "13 calls this hour · 1 client", + "the row on screen must show the new count, not the one captured at menu-open time") + } + + /// Why the scheduler had to change — and the proof that the pump above is + /// selective rather than draining everything. + /// + /// Combine's `RunLoop` scheduler installs its timers in `.default` mode + /// only, so nothing it schedules is serviced while a menu tracks. Without + /// this control a reader could not tell whether the test above passes + /// because the fix works or because the pump services every mode. + func testTheRunLoopSchedulerIsNeverServicedWhileTheMenuIsTracking() { + requireEventTrackingIsACommonMode() + + let subject = PassthroughSubject() + var fired = 0 + let token = subject + .debounce(for: .milliseconds(Self.debounceMs), scheduler: RunLoop.main) + .sink { fired += 1 } + defer { token.cancel() } + + subject.send(()) + Self.pump(.eventTracking, until: { fired > 0 }, timeout: 2.0) + + XCTAssertEqual(fired, 0, + "RunLoop.main schedules in .default mode only; if this ever fires under an event-tracking-only pump, this suite has lost its ability to detect the freeze") + + // And it was only the mode holding it back: one turn of the default mode + // releases that very same subscription. + Self.pump(.default, until: { fired > 0 }) + XCTAssertEqual(fired, 1, "the delivery was deferred by the run-loop mode, not lost") + } + + // MARK: - No regression when no menu is open + + /// The debounce still coalesces a burst into a single delivery, on the main + /// thread. Changing the scheduler must not turn the 500 ms coalescing window + /// into a repaint per event. + func testTheDebounceStillCoalescesABurstIntoOneMainThreadDelivery() { + requireEventTrackingIsACommonMode() + + let subject = PassthroughSubject() + var deliveries = 0 + var everOffMain = false + let token = subject + .debounce(for: .milliseconds(Self.debounceMs), + scheduler: AppController.menuRefreshScheduler) + .sink { _ in + if !Thread.isMainThread { everOffMain = true } + deliveries += 1 + } + defer { token.cancel() } + + for _ in 0..<20 { subject.send(()) } + + // Well past the window, so a second delivery would have shown up. + let settled = Date().addingTimeInterval(1.5) + while Date() < settled { + RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.05)) + } + + XCTAssertEqual(deliveries, 1, "20 events inside the window must repaint the menu once") + XCTAssertFalse(everOffMain, "menu rebuilds must arrive on the main thread") + } + + /// Two events separated by a quiet gap are two repaints, not one — i.e. the + /// window really is the debounce and not something the queue swallowed. + func testEventsSeparatedByTheWindowAreDeliveredSeparately() { + requireEventTrackingIsACommonMode() + + let subject = PassthroughSubject() + var deliveries = 0 + let token = subject + .debounce(for: .milliseconds(Self.debounceMs), + scheduler: AppController.menuRefreshScheduler) + .sink { _ in deliveries += 1 } + defer { token.cancel() } + + subject.send(()) + Self.pump(.default, until: { deliveries == 1 }) + subject.send(()) + Self.pump(.default, until: { deliveries == 2 }) + + XCTAssertEqual(deliveries, 2) + } + + // MARK: - Helpers + + /// Pump the main run loop in one mode only, until `done` or the deadline. + private static func pump(_ mode: RunLoop.Mode, + until done: () -> Bool, + timeout: TimeInterval = 3.0) { + let deadline = Date().addingTimeInterval(timeout) + while !done() && Date() < deadline { + RunLoop.main.run(mode: mode, before: Date().addingTimeInterval(0.02)) + } + } + + private static let now = GlanceFixtures.now + + private final class ClickStub: NSObject { + @objc func openGlanceRow(_ sender: NSMenuItem) {} + } + + private static let clickStub = ClickStub() + + private static func makeSection() -> GlanceSection { + GlanceSection(target: clickStub, action: #selector(ClickStub.openGlanceRow(_:))) + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/SSEStreamSessionTests.swift b/native/macos/MCPProxy/MCPProxyTests/SSEStreamSessionTests.swift new file mode 100644 index 000000000..3af8e7746 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/SSEStreamSessionTests.swift @@ -0,0 +1,257 @@ +import XCTest +import Combine +@testable import MCPProxy + +/// WHERE the connection generation is captured, not merely whether it is +/// checked. +/// +/// The publish-helper tests in `GlanceReconnectGenerationTests` hand +/// `prependGlanceActivity` a generation the test picked, so they verify the +/// final guard and nothing before it. They pass identically against the +/// implementation that reads the generation when an event ARRIVES — which, +/// after a reconnect, is the new generation, so the guard admits the dead +/// core's row and the whole fix evaporates. These tests drive the stream +/// instead, and force the reconnect into the window between the stream opening +/// and the event being delivered. +/// +/// `CoreProcessManagerSSEWiringTests`, below, covers the other half: that +/// production actually routes through the helper. Keeping the body to a single +/// call was not enough — reinstating the arrival-time read directly in +/// `startSSEStream` left every test in this file green. +@MainActor +final class SSEStreamSessionTests: XCTestCase { + + private func connectedState() -> AppState { + let state = AppState() + state.coreState = .connected + return state + } + + private static func entry(id: String) throws -> ActivityEntry { + let json = """ + {"id":"\(id)","type":"tool_call","status":"success","timestamp":"2026-07-29T11:00:00Z", + "server_name":"srv","tool_name":"t","request_id":"\(id)"} + """ + // swiftlint:disable:next force_unwrapping + return try JSONDecoder().decode(ActivityEntry.self, from: json.data(using: .utf8)!) + } + + /// Run one session whose stream delivers a single event, with `between` + /// executed after the stream has been opened and before the event is + /// yielded. Returns when the session has finished. + /// + /// The handshake is deliberately on `open`, NOT on `captureGeneration`. + /// `open` is called at the same point by every implementation, so the test's + /// sequencing does not depend on the thing it is testing; hanging the + /// handshake off the capture makes a wrong implementation fail by timeout + /// instead of by assertion, which is a much worse diagnostic. + private func runSession( + state: AppState, + entryID: String, + between: @escaping @MainActor () -> Void + ) async throws { + let (stream, continuation) = AsyncStream.makeStream() + let opened = expectation(description: "the session opened its stream") + let entry = try Self.entry(id: entryID) + + let session = Task { + await SSEStreamSession.run( + captureGeneration: { await MainActor.run { state.connectionGeneration } }, + open: { + await MainActor.run { opened.fulfill() } + return stream + }, + handle: { _, generation in + await MainActor.run { + state.prependGlanceActivity(entry, generation: generation) + } + } + ) + } + + await fulfillment(of: [opened], timeout: 5) + between() + continuation.yield(SSEEvent(event: GlanceEvent.upstreamCompleted, data: "{}", retry: nil, id: nil)) + continuation.finish() + await session.value + } + + /// The core dies and comes back while the stream is open. The event was read + /// from the OLD core's stream, so it must not reach the new core's feed — + /// even though `coreState` is `.connected` again by the time it publishes. + func testAnEventDeliveredAfterAReconnectDoesNotPublish() async throws { + let state = connectedState() + + try await runSession(state: state, entryID: "from-the-dead-core") { + state.coreState = .reconnecting(attempt: 1) + state.coreState = .connected + } + + XCTAssertTrue(state.glanceActivity.isEmpty, + "an event from the previous connection's stream published into the new one") + } + + /// Positive control: with no reconnection the same session publishes, so the + /// test above pins the reconnect rather than a stream that never delivers. + func testAnEventOnAnUninterruptedStreamPublishes() async throws { + let state = connectedState() + + try await runSession(state: state, entryID: "live") {} + + XCTAssertEqual(state.glanceActivity.map(\.id), ["live"]) + } + + /// A reconnect DURING the connect itself invalidates the session too: the + /// generation is captured before the stream is opened, so a session that + /// loses its core while connecting cannot adopt the replacement. + func testAReconnectWhileTheStreamIsOpeningInvalidatesTheSession() async throws { + let state = connectedState() + let (stream, continuation) = AsyncStream.makeStream() + let entry = try Self.entry(id: "from-the-dead-core") + + let session = Task { + await SSEStreamSession.run( + captureGeneration: { await MainActor.run { state.connectionGeneration } }, + open: { + await MainActor.run { + state.coreState = .reconnecting(attempt: 1) + state.coreState = .connected + } + return stream + }, + handle: { _, generation in + await MainActor.run { + state.prependGlanceActivity(entry, generation: generation) + } + } + ) + } + + continuation.yield(SSEEvent(event: GlanceEvent.upstreamCompleted, data: "{}", retry: nil, id: nil)) + continuation.finish() + await session.value + + XCTAssertTrue(state.glanceActivity.isEmpty) + } +} + +/// The production wiring, driven end to end through `CoreProcessManager`. +/// +/// `SSEStreamSessionTests` pins the rule; this pins that the manager uses it. +/// Both are needed, and the reason is concrete: with the helper left correct, +/// reinstating the arrival-time generation read inside `startSSEStream` passed +/// every other test in the suite. The seam this needs is one internal method on +/// the actor (`installSSEClient`) plus an internal `startSSEStream`. +@MainActor +final class CoreProcessManagerSSEWiringTests: XCTestCase { + + /// A stream source the test drives by hand. + private actor StubSSEClient: SSEStreaming { + private let stream: AsyncStream + private let onConnect: @Sendable () -> Void + + init(stream: AsyncStream, onConnect: @escaping @Sendable () -> Void) { + self.stream = stream + self.onConnect = onConnect + } + + func connect() async -> AsyncStream { + onConnect() + return stream + } + + func disconnect() async {} + } + + private static func upstreamCompletedPayload(requestID: String) -> String { + """ + {"payload":{"server_name":"srv","tool_name":"t","status":"success", + "request_id":"\(requestID)","session_id":"sess-1","duration_ms":5}, + "timestamp":1785340000} + """ + } + + /// A `status` event, whose handling is deliberately NOT generation-guarded. + /// It is the test's ordering marker: the manager handles events in stream + /// order, so once this one's effect is visible the glance event before it + /// has already been processed. That makes the negative assertion below + /// deterministic instead of a timeout. + private static func statusPayload(totalTools: Int) -> String { + """ + {"upstream_stats":{"total_servers":1,"total_tools":\(totalTools)}} + """ + } + + /// The core dies and comes back while the manager's stream is open. The row + /// read from the old core's stream must not reach the new core's feed — + /// through the real `startSSEStream`, not through the helper directly. + func testTheManagerCapturesTheGenerationWhenItsStreamOpens() async throws { + let state = AppState() + state.coreState = .connected + let manager = CoreProcessManager(appState: state, notificationService: NotificationService()) + + let (stream, continuation) = AsyncStream.makeStream() + let connected = expectation(description: "the manager opened its stream") + let stub = StubSSEClient(stream: stream) { connected.fulfill() } + + await manager.installSSEClient(stub) + await manager.startSSEStream() + await fulfillment(of: [connected], timeout: 5) + + // The core goes away and a new one takes its place. + state.coreState = .reconnecting(attempt: 1) + state.coreState = .connected + + continuation.yield(SSEEvent(event: GlanceEvent.upstreamCompleted, + data: Self.upstreamCompletedPayload(requestID: "from-the-dead-core"), + retry: nil, id: nil)) + continuation.yield(SSEEvent(event: "status", + data: Self.statusPayload(totalTools: 77), + retry: nil, id: nil)) + + // Wait for the marker, which proves the glance event was handled first. + let marker = expectation(description: "the status event was handled") + let sink = state.$totalTools.sink { if $0 == 77 { marker.fulfill() } } + await fulfillment(of: [marker], timeout: 5) + sink.cancel() + + XCTAssertTrue(state.glanceActivity.isEmpty, + "the manager published a row from the previous connection's stream") + + // Leave `.connected` before the stream ends, so the manager's + // disconnect path returns immediately instead of trying to reconnect. + state.coreState = .idle + continuation.finish() + } + + /// Positive control through the same path: with no reconnection the row + /// arrives, so the test above pins the reconnect rather than a stream the + /// manager never reads. + func testTheManagerPublishesRowsFromItsOwnConnection() async throws { + let state = AppState() + state.coreState = .connected + let manager = CoreProcessManager(appState: state, notificationService: NotificationService()) + + let (stream, continuation) = AsyncStream.makeStream() + let connected = expectation(description: "the manager opened its stream") + let stub = StubSSEClient(stream: stream) { connected.fulfill() } + + await manager.installSSEClient(stub) + await manager.startSSEStream() + await fulfillment(of: [connected], timeout: 5) + + continuation.yield(SSEEvent(event: GlanceEvent.upstreamCompleted, + data: Self.upstreamCompletedPayload(requestID: "live"), + retry: nil, id: nil)) + + let published = expectation(description: "the row reached the feed") + let sink = state.$glanceActivity.sink { if !$0.isEmpty { published.fulfill() } } + await fulfillment(of: [published], timeout: 5) + sink.cancel() + + XCTAssertEqual(state.glanceActivity.map(\.requestId), ["live"]) + + state.coreState = .idle + continuation.finish() + } +} diff --git a/oas/docs.go b/oas/docs.go index 81314b029..1335ea4a1 100644 --- a/oas/docs.go +++ b/oas/docs.go @@ -9,7 +9,7 @@ const docTemplate = `{ "components": {"schemas":{"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_max_size_mb":{"description":"Max total activity-log size in MB before pruning oldest (default: 256, 0=disabled)","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"allow_private_registry_fetch":{"description":"AllowPrivateRegistryFetch opts out of the registry SSRF guard (MCP-1076,\nCWE-918). By default (false) registry fetches refuse any host that is — or\nresolves to — a non-routable address (loopback, RFC1918/CGNAT private,\nlink-local incl. the 169.254.169.254 cloud-metadata endpoint), so a\nmalicious or typo'd registry source cannot turn the daemon into a\nrequest-forgery vector against internal services.\n\nThis opt-out is BLANKET (all-or-nothing): setting it true disables the\nguard for EVERY non-routable range at once — loopback, RFC1918/CGNAT\nprivate, link-local AND the 169.254.169.254 cloud-metadata endpoint. There\nis no way to allow only loopback; enabling it for a localhost dev registry\nalso re-opens the cloud-metadata SSRF vector. Set true ONLY when you\nintentionally run a trusted registry mirror on an internal/private address,\nideally on a host with no cloud-metadata exposure. The change takes effect\nonly on daemon (re)start or config reload.","type":"boolean"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"api_key":{"description":"Security settings","type":"string"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"disable_management":{"type":"boolean"},"docker_isolation":{"$ref":"#/components/schemas/config.DockerIsolationConfig"},"docker_recovery":{"$ref":"#/components/schemas/config.DockerRecoveryConfig"},"enable_code_execution":{"description":"Code execution settings","type":"boolean"},"enable_prompts":{"description":"Prompts settings","type":"boolean"},"enable_socket":{"description":"Enable Unix socket/named pipe for local IPC (default: true)","type":"boolean"},"enable_tray":{"description":"Deprecated: EnableTray is unused and has no runtime effect. Kept for backward compatibility.","type":"boolean"},"environment":{"$ref":"#/components/schemas/secureenv.EnvConfig"},"features":{"$ref":"#/components/schemas/config.FeatureFlags"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"init_timeout":{"description":"InitTimeout is the global default deadline for an upstream's MCP\n` + "`" + `initialize` + "`" + ` handshake (MCP-3322 / GH #760). *Duration tri-state: nil =\ninherit the built-in 30s default; a positive value = that deadline. A\nper-server InitTimeout overrides this. Resolved by ResolveInitTimeout;\nvalidated to {0} ∪ [1s, 30m] in Validate(). Servers doing legitimate\nfirst-run warmup (cache/index build) before answering ` + "`" + `initialize` + "`" + ` can\nraise this so they are not killed mid-startup.","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_result_size_chars":{"description":"Advertised on every tool as ` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; raises Claude Code's inline-response ceiling from 50k to up to 500k chars. Set to 0 to disable.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of the\nsecret-bearing server fields — sensitive header values (Authorization,\nX-API-Key, Cookie, …), env-var secrets, and URL query credentials — in\nresponses from the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + `\nREST API, and the SSE event stream. It also lets URL secrets echoed\ninto last_error / health.detail through unscrubbed.\n\nDefault false — sensitive values are surfaced masked as\n` + "`" + `••••\u003clast2\u003e (\u003cN\u003e chars)` + "`" + ` (error strings use ` + "`" + `***REDACTED***` + "`" + `) so an\nMCP agent cannot read Bearer tokens / API keys / URL secrets out of\nanother upstream's config (PR #425, issue #872). ${env:…}/${keyring:…}\nreferences are labels, not secrets, and pass through unchanged.\n\nThe Web UI / macOS tray edit forms work without seeing the real\nvalues: PATCH /api/v1/servers/{id} deep-merges (omitted keys are\npreserved, see ` + "`" + `headers_remove` + "`" + ` / ` + "`" + `env_remove` + "`" + ` for explicit\ndeletes), so clients compute a diff and only send the keys that\nactually changed. Redacted-but-unchanged values never round-trip\n— the backend keeps the real string. Set this to true if a\ndownstream tool genuinely needs raw values in the response.","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"security":{"$ref":"#/components/schemas/config.SecurityConfig"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_discovery_interval":{"type":"string"},"tool_response_limit":{"type":"integer"},"tool_response_mode":{"description":"Tool response mode (Spec 085): how retrieve_tools serializes results.\nValid values: \"\" (= full), \"full\" (default: today's schema-bearing\nentries), \"compact\" (signature + first-sentence entries). Orthogonal to\nrouting_mode — routing_mode selects the tool SURFACE, this selects the\nSERIALIZATION within the retrieve_tools surface. Serialization-only: it\nnever affects the query, ranking, or result set. Hot-reloadable.","type":"string"},"tool_response_session_risk_warning":{"description":"ToolResponseSessionRiskWarning controls whether the prose ` + "`" + `warning` + "`" + ` field\nis included in the ` + "`" + `session_risk` + "`" + ` object returned by ` + "`" + `retrieve_tools` + "`" + `.\nThe structured fields (level, lethal_trifecta, has_open_world_tools, etc.)\nare always included. Default: false (quiet for LLM clients) — see issue #406.\nMost tools lack annotations, so the MCP-spec defaults treat them as fully\npermissive across all three risk axes, which makes the prose warning fire\non almost every call and wastes tokens.","type":"boolean"},"tools_limit":{"type":"integer"},"toon_min_savings_pct":{"description":"ToonMinSavingsPct is the minimum byte-savings percentage (validated\n1-90; 0/unset → 15) the complete TOON emission (marker + hint + body)\nmust achieve over the exact passthrough emission for adaptive mode to\nencode a block. Byte savings approximate token savings for the tabular\npayload class; the spec-083 profiler reports true token deltas.\nGlobal-only (no per-server override, FR-001).","type":"integer"},"toon_output":{"description":"ToonOutput selects the TOON encoding mode for call_tool_* result text\nblocks (spec 084): \"off\" (default — responses byte-identical to\npre-feature behavior), \"adaptive\" (encode only tabular-uniform payloads\nthat beat compact JSON by ToonMinSavingsPct), or \"always\"\n(benchmark/debug only — encodes every JSON-parseable block and can\nINCREASE token cost). Per-server override: ServerConfig.ToonOutput.\nResolved by ResolveToonOutput; hot-reloadable.","type":"string"},"top_k":{"description":"Deprecated: TopK is superseded by ToolsLimit and has no runtime effect. Kept for backward compatibility.","type":"integer"},"tray_endpoint":{"description":"Tray endpoint override (unix:// or npipe://)","type":"string"},"trusted_hosts":{"description":"TrustedHosts lists non-loopback Host header values accepted on loopback\nlisteners (GH #898). DNS-rebinding protection rejects requests whose Host\nheader is not a loopback address when mcpproxy listens on loopback; a\nreverse proxy (nginx → 127.0.0.1) forwarding the public domain in Host\ntrips it. Entries are hostnames, case-insensitive; an entry without a\nport matches any port, with a port it must match exactly; a leading dot\n(\".example.com\") is a subdomain wildcard. The single entry \"*\" disables\nHost and Origin validation entirely. The same list also validates the\nOrigin header when present (MCP spec DNS-rebinding defense). Empty\n(default) keeps full protection. Env override: MCPPROXY_TRUSTED_HOSTS\n(comma-separated).","items":{"type":"string"},"type":"array","uniqueItems":false},"update_check":{"$ref":"#/components/schemas/config.UpdateCheckConfig"}},"type":"object"},"config.CustomPattern":{"properties":{"category":{"description":"Category (defaults to \"custom\")","type":"string"},"keywords":{"description":"Keywords to match (mutually exclusive with Regex)","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Unique identifier for this pattern","type":"string"},"regex":{"description":"Regex pattern (mutually exclusive with Keywords)","type":"string"},"severity":{"description":"Risk level: critical, high, medium, low","type":"string"}},"type":"object"},"config.DeepScanConfig":{"description":"DeepScan is the opt-in \"deep scan\" layer (Spec 077 US3). It subsumes the\ndeprecated top-level scanner_fetch_package_source / scanner_disable_no_new_privileges\nkeys (migrated on load) and gates the heavy Docker-based scanners + source\nextraction. Disabled by default (FR-006): only the deterministic in-process\nbaseline scanner runs. A deep-scan failure NEVER changes the baseline verdict\n(FR-007/FR-008).","properties":{"disable_no_new_privileges":{"description":"DisableNoNewPrivileges, when true, omits the ` + "`" + `--security-opt\nno-new-privileges` + "`" + ` flag from scanner container runs (snap-docker/AppArmor\nescape hatch). Absorbs the deprecated top-level\nscanner_disable_no_new_privileges. Default false.","type":"boolean"},"enabled":{"description":"Enabled is the master opt-in for the heavy layer (FR-006). Default false.","type":"boolean"},"fetch_package_source":{"description":"FetchPackageSource controls whether the scanner fetches the PUBLISHED\nsource of package-runner servers (npx/uvx) — without executing it — when\nno local source is available. Absorbs the deprecated top-level\nscanner_fetch_package_source. Default (nil) is ENABLED within deep scan.","type":"boolean"},"scanners":{"description":"Scanners optionally restricts which deep scanners may run under the\numbrella (by scanner id). Empty ⇒ all enabled deep scanners are eligible.","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.DockerIsolationConfig":{"description":"Docker isolation settings","properties":{"cpu_limit":{"description":"CPU limit for containers","type":"string"},"default_images":{"additionalProperties":{"type":"string"},"description":"Map of runtime type to Docker image","type":"object"},"enable_cache_volume":{"description":"Mount shared cache volumes for faster restarts (default: true)","type":"boolean"},"enabled":{"description":"Global enable/disable for Docker isolation (legacy; superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments","items":{"type":"string"},"type":"array","uniqueItems":false},"log_driver":{"description":"Docker log driver (default: json-file)","type":"string"},"log_max_files":{"description":"Maximum number of log files (default: 3)","type":"string"},"log_max_size":{"description":"Maximum size of log files (default: 100m)","type":"string"},"memory_limit":{"description":"Memory limit for containers","type":"string"},"mode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"network_mode":{"description":"Docker network mode (default: bridge)","type":"string"},"registry":{"description":"Custom registry (defaults to docker.io)","type":"string"},"timeout":{"description":"Container startup timeout","type":"string"}},"type":"object"},"config.DockerRecoveryConfig":{"description":"Docker recovery settings","properties":{"enabled":{"description":"Enable Docker recovery monitoring (default: true)","type":"boolean"},"max_retries":{"description":"Maximum retry attempts (0 = unlimited)","type":"integer"},"notify_on_failure":{"description":"Show notification on recovery failure (default: true)","type":"boolean"},"notify_on_retry":{"description":"Show notification on each retry (default: false)","type":"boolean"},"notify_on_start":{"description":"Show notification when recovery starts (default: true)","type":"boolean"},"notify_on_success":{"description":"Show notification on successful recovery (default: true)","type":"boolean"},"persistent_state":{"description":"Save recovery state across restarts (default: true)","type":"boolean"}},"type":"object"},"config.FeatureFlags":{"description":"Deprecated: Features flags are unused and have no runtime effect. Kept for backward compatibility.","properties":{"enable_async_storage":{"type":"boolean"},"enable_caching":{"type":"boolean"},"enable_contract_tests":{"type":"boolean"},"enable_debug_logging":{"description":"Development features","type":"boolean"},"enable_docker_isolation":{"type":"boolean"},"enable_event_bus":{"type":"boolean"},"enable_health_checks":{"type":"boolean"},"enable_metrics":{"type":"boolean"},"enable_oauth":{"description":"Security features","type":"boolean"},"enable_observability":{"description":"Observability features","type":"boolean"},"enable_quarantine":{"type":"boolean"},"enable_runtime":{"description":"Runtime features","type":"boolean"},"enable_search":{"description":"Storage features","type":"boolean"},"enable_sse":{"type":"boolean"},"enable_tracing":{"type":"boolean"},"enable_tray":{"type":"boolean"},"enable_web_ui":{"description":"UI features","type":"boolean"}},"type":"object"},"config.IntentDeclarationConfig":{"description":"Intent declaration settings (Spec 018)","properties":{"strict_server_validation":{"description":"StrictServerValidation controls whether server annotation mismatches\ncause rejection (true) or just warnings (false).\nDefault: true (reject mismatches)","type":"boolean"}},"type":"object"},"config.IsolationConfig":{"description":"Per-server isolation settings","properties":{"enabled":{"description":"Enable Docker isolation for this server (nil = inherit global; legacy, superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments for this server","items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"description":"Custom Docker image (overrides default)","type":"string"},"log_driver":{"description":"Docker log driver override for this server","type":"string"},"log_max_files":{"description":"Maximum number of log files override","type":"string"},"log_max_size":{"description":"Maximum size of log files override","type":"string"},"mode":{"$ref":"#/components/schemas/config.IsolationMode"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"config.IsolationMode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"config.LogConfig":{"description":"Logging configuration","properties":{"compress":{"type":"boolean"},"enable_console":{"type":"boolean"},"enable_file":{"type":"boolean"},"filename":{"type":"string"},"json_format":{"type":"boolean"},"level":{"type":"string"},"log_dir":{"description":"Custom log directory","type":"string"},"max_age":{"description":"days","type":"integer"},"max_backups":{"description":"number of backup files","type":"integer"},"max_size":{"description":"MB","type":"integer"}},"type":"object"},"config.MetricsExporterConfig":{"description":"Metrics gates the Prometheus /metrics scrape endpoint (MCP-32). Disabled\nby default — operators opt in for k8s/enterprise deployments.","properties":{"enabled":{"description":"Enabled exposes /metrics on the existing HTTP listener when true.","type":"boolean"}},"type":"object"},"config.OAuthConfig":{"description":"OAuth configuration (keep even when empty to signal OAuth requirement)","properties":{"client_id":{"type":"string"},"client_secret":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters (e.g., RFC 8707 resource)","type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_uri":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ObservabilityConfig":{"description":"Observability settings (Spec 069): usage aggregate cache/persistence cadence.","properties":{"metrics":{"$ref":"#/components/schemas/config.MetricsExporterConfig"},"tracing":{"$ref":"#/components/schemas/config.TracingExporterConfig"},"usage_cache_ttl":{"description":"UsageCacheTTL bounds the freshness of the usage endpoint's read cache for\nwide windows (FR-005). Default 5s.","type":"string"},"usage_persist_interval":{"description":"UsagePersistInterval is how often the actor-owned usage aggregate snapshot\nis flushed to storage. Default 30s.","type":"string"}},"type":"object"},"config.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.ProfileConfig":{"properties":{"name":{"description":"URL slug, validated","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"deep_scan":{"$ref":"#/components/schemas/config.DeepScanConfig"},"integrity_check_interval":{"type":"string"},"integrity_check_on_restart":{"type":"boolean"},"runtime_read_only":{"type":"boolean"},"runtime_tmpfs_size":{"type":"string"},"scan_timeout_default":{"type":"string"},"scanner_disable_no_new_privileges":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.DisableNoNewPrivileges\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.IsDisableNoNewPrivileges. Cleared after migration.\n\nScannerDisableNoNewPrivileges, when true, omits the\n` + "`" + `--security-opt no-new-privileges` + "`" + ` flag from scanner container runs.\n\nBackground: snap-installed Docker on Ubuntu confines dockerd under the\n` + "`" + `snap.docker.dockerd` + "`" + ` AppArmor profile. When runc tries to transition\nthe container into the inner ` + "`" + `docker-default` + "`" + ` profile to exec the\nentrypoint, AppArmor refuses the transition because NO_NEW_PRIVS\nforbids privilege/profile changes on exec — the result is EPERM\n(\"operation not permitted\") and every scanner fails immediately.\n\nSet this to true ONLY on hosts hitting that incompatibility. Scanner\ncontainers still run with read-only rootfs, tmpfs /tmp, no-network by\ndefault, and read-only source mounts, so the marginal isolation loss\nis small. The preferred fix remains replacing snap docker with a\ndistro-packaged docker.","type":"boolean"},"scanner_fetch_package_source":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.FetchPackageSource\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.EffectiveFetchPackageSource. Cleared after migration.\n\nScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"init_timeout":{"description":"InitTimeout overrides the global init_timeout for this server's MCP\n` + "`" + `initialize` + "`" + ` handshake deadline (MCP-3322 / GH #760). *Duration tri-state:\nnil = inherit the global value (or 30s default), positive = that deadline.\nResolved by Config.ResolveInitTimeout; validated to {0} ∪ [1s, 30m]. Raise\nthis for upstreams that do legitimate first-run warmup (e.g. caching many\nchannels/users) before responding to ` + "`" + `initialize` + "`" + `.","type":"string"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"launcher_wait_timeout":{"description":"LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched\nHTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted\nwhen the server is configured with both Command and an HTTP/SSE URL — i.e.,\nmcpproxy starts the process AND connects via network. Stdio servers ignore\nthis field. Zero or unset → 30s default.","type":"string"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/config.OAuthConfig"},"protocol":{"description":"stdio, http, sse, streamable-http, auto","type":"string"},"quarantined":{"description":"Security quarantine status","type":"boolean"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets a disconnected server","type":"boolean"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"toon_output":{"description":"ToonOutput overrides the global toon_output mode for this server's\ntools (spec 084, FR-001). Plain string, not a pointer: \"\"/absent =\ninherit the global value; \"off\"|\"adaptive\"|\"always\" = override (\"off\"\nis the explicit force-off). Resolved by Config.ResolveToonOutput.","type":"string"},"trust_mode":{"description":"TrustMode is the per-server trust tier: auto|scan|manual. Supersedes\nauto_approve_tool_changes (spec 086). An empty value is derived from the\nlegacy fields at load via normalizeServerQuarantineFlags; the single\nresolution point is EffectiveTrustMode(), which treats an empty or\nunrecognized value as manual (secure by default). Read via\nEffectiveTrustMode(), never the raw string.","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TelemetryConfig":{"description":"Telemetry settings (Spec 036)","properties":{"anonymous_id":{"description":"Auto-generated UUIDv4","type":"string"},"anonymous_id_created_at":{"description":"Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"},"last_reported_version":{"description":"Upgrade funnel","type":"string"},"last_startup_outcome":{"description":"success|port_conflict|db_locked|...","type":"string"},"notice_shown":{"description":"First-run notice flag","type":"boolean"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"config.TracingExporterConfig":{"description":"Tracing gates the OpenTelemetry OTLP trace exporter (MCP-32). Disabled by\ndefault.","properties":{"enabled":{"description":"Enabled turns on OTLP trace export for tool calls and upstream hops.","type":"boolean"},"endpoint":{"description":"Endpoint is the collector address as host:port (no scheme), e.g.\n\"localhost:4318\" for http or \"localhost:4317\" for grpc.","type":"string"},"protocol":{"description":"Protocol selects the OTLP transport: \"http\" or \"grpc\".","type":"string"},"sample_rate":{"description":"SampleRate is the head-based trace sampling ratio in [0,1]. Default 0.1.","type":"number"}},"type":"object"},"config.UpdateCheckConfig":{"description":"Update-check settings (Spec 079 FR-012): config-file control of the\nbackground upgrade-awareness checker (internal/updatecheck). nil =\nenabled on the stable channel (existing default behavior). The existing\nenvironment switches keep working and WIN over these keys (FR-014):\nMCPPROXY_DISABLE_AUTO_UPDATE=true force-disables even when\nenabled=true, and MCPPROXY_ALLOW_PRERELEASE_UPDATES=true force-selects\nthe rc channel even when channel=stable.","properties":{"channel":{"description":"Channel selects which releases are offered as updates: \"stable\"\n(default; prereleases never offered) or \"rc\" (prereleases included).\nEmpty resolves to stable. Validated in ValidateDetailed.","type":"string"},"enabled":{"description":"Enabled gates all update checking. Tri-state: nil/absent = enabled\n(default true, matching pre-079 behavior). When false, no network\ncheck is performed and no upgrade nudge appears on any surface\n(FR-015) — /api/v1/info omits the update object entirely.","type":"boolean"}},"type":"object"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\"","type":"string"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"arguments":{"description":"Tool call arguments","type":"object"},"detection_types":{"description":"List of detection types found","items":{"type":"string"},"type":"array","uniqueItems":false},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP transport session ID (regenerated on every reconnect)","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"},"work_session_id":{"description":"Spec 082: one client, one project, across reconnects","type":"string"}},"type":"object"},"contracts.ActivitySource":{"description":"How activity was triggered: \"mcp\", \"cli\", \"api\"","type":"string","x-enum-varnames":["ActivitySourceMCP","ActivitySourceCLI","ActivitySourceAPI"]},"contracts.ActivitySummaryResponse":{"properties":{"blocked_count":{"description":"Count of blocked activities","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"contracts.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeepScanDescriptor":{"description":"DeepScan reports the opt-in \"deep scan\" layer status (Spec 077 US3),\nSEPARATELY from the baseline verdict above. Always emitted on a computed\nsummary — when deep scan is off (the default) it reports enabled=false\nplus any enabled-but-skipped Docker scanners. It never influences Status.","properties":{"available":{"type":"boolean"},"enabled":{"type":"boolean"},"ran":{"type":"boolean"},"scanners_failed":{"items":{"$ref":"#/components/schemas/contracts.DeepScanScannerFailure"},"type":"array","uniqueItems":false},"skipped_scanners":{"description":"SkippedScanners lists Docker scanners the user enabled that are skipped\nbecause security.deep_scan.enabled is false (informational).","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DeepScanScannerFailure":{"properties":{"id":{"type":"string"},"reason":{"type":"string"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostic":{"description":"Spec 044 — structured diagnostic error and stable error code. Both\nare populated when the server is in a failed state and the error\nhas been classified by internal/diagnostics. Healthy servers omit\nthese fields.","properties":{"cause":{"type":"string"},"code":{"type":"string"},"detected_at":{"type":"string"},"docs_url":{"type":"string"},"fix_steps":{"items":{"$ref":"#/components/schemas/contracts.DiagnosticFixStep"},"type":"array","uniqueItems":false},"severity":{"type":"string"},"user_message":{"type":"string"}},"type":"object"},"contracts.DiagnosticFixStep":{"properties":{"command":{"type":"string"},"destructive":{"type":"boolean"},"fixer_key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.EditRegistrySourceRequest":{"properties":{"name":{"description":"new display name","type":"string"},"servers_url":{"description":"explicit servers-collection URL","type":"string"},"url":{"description":"new base/servers https URL","type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.FindingCounts":{"properties":{"dangerous":{"description":"Tool poisoning, active prompt injection","type":"integer"},"info":{"description":"Low-severity CVEs, informational","type":"integer"},"total":{"type":"integer"},"warning":{"description":"Rug pull, supply chain CVEs with exploits","type":"integer"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object","type":"object"},"config_path":{"description":"Path to config file","type":"string"}},"type":"object"},"contracts.GetRegistriesResponse":{"properties":{"registries":{"items":{"$ref":"#/components/schemas/contracts.Registry"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerLogsResponse":{"properties":{"count":{"type":"integer"},"logs":{"items":{"$ref":"#/components/schemas/contracts.LogEntry"},"type":"array","uniqueItems":false},"server_name":{"type":"string"}},"type":"object"},"contracts.GetServerToolCallsResponse":{"properties":{"server_name":{"type":"string"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerToolsResponse":{"properties":{"count":{"type":"integer"},"server_name":{"type":"string"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GetServersResponse":{"properties":{"servers":{"items":{"$ref":"#/components/schemas/contracts.Server"},"type":"array","uniqueItems":false},"stats":{"$ref":"#/components/schemas/contracts.ServerStats"}},"type":"object"},"contracts.GetSessionDetailResponse":{"properties":{"session":{"$ref":"#/components/schemas/contracts.MCPSession"}},"type":"object"},"contracts.GetSessionsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"sessions":{"items":{"$ref":"#/components/schemas/contracts.MCPSession"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetToolCallDetailResponse":{"properties":{"tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"}},"type":"object"},"contracts.GetToolCallsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GlobalToolsResponse":{"properties":{"failed_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"partial":{"type":"boolean"},"stats":{"$ref":"#/components/schemas/contracts.GlobalToolsStats"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GlobalToolsStats":{"properties":{"disabled":{"type":"integer"},"enabled":{"type":"integer"},"pending_approval":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.HealthStatus":{"description":"Unified health status calculated by the backend","properties":{"action":{"description":"Action is the suggested fix action: \"login\", \"restart\", \"enable\", \"approve\", \"view_logs\", \"set_secret\", \"configure\", or \"\" (none)","type":"string"},"admin_state":{"description":"AdminState indicates the admin state: \"enabled\", \"disabled\", or \"quarantined\"","type":"string"},"detail":{"description":"Detail is an optional longer explanation of the status","type":"string"},"level":{"description":"Level indicates the health level: \"healthy\", \"degraded\", or \"unhealthy\"","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"}},"type":"object"},"contracts.InfoEndpoints":{"description":"Available API endpoints","properties":{"http":{"description":"HTTP endpoint address (e.g., \"127.0.0.1:8080\")","type":"string"},"socket":{"description":"Unix socket path (empty if disabled)","type":"string"}},"type":"object"},"contracts.InfoResponse":{"properties":{"endpoints":{"$ref":"#/components/schemas/contracts.InfoEndpoints"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"network_mode":{"type":"string"},"timeout":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationDefaults":{"description":"IsolationDefaults exposes the resolved baseline values that\nwould apply when no per-server override is set. Populated on\nlist/get responses; never consumed on PATCH requests.","properties":{"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"runtime_type":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.LogEntry":{"properties":{"fields":{"type":"object"},"level":{"type":"string"},"message":{"type":"string"},"server":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.MCPSession":{"properties":{"client_name":{"type":"string"},"client_version":{"type":"string"},"end_time":{"type":"string"},"experimental":{"items":{"type":"string"},"type":"array","uniqueItems":false},"has_roots":{"description":"MCP Client Capabilities","type":"boolean"},"has_sampling":{"type":"boolean"},"id":{"type":"string"},"last_activity":{"type":"string"},"start_time":{"type":"string"},"status":{"type":"string"},"tool_call_count":{"type":"integer"},"total_tokens":{"type":"integer"},"work_session_id":{"type":"string"},"workspace_name":{"description":"Workspace / work session (Spec 082). WorkspaceName is the project's\nbasename — the full local path is never exposed. WorkSessionID groups the\nreconnects that make up one stretch of user work.","type":"string"}},"type":"object"},"contracts.MetadataStatus":{"properties":{"authorization_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"error":{"type":"string"},"found":{"type":"boolean"},"url_checked":{"type":"string"}},"type":"object"},"contracts.MissingSecretInfo":{"properties":{"secret_name":{"type":"string"},"used_by":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.NPMPackageInfo":{"properties":{"exists":{"type":"boolean"},"install_cmd":{"type":"string"}},"type":"object"},"contracts.OAuthConfig":{"properties":{"auth_url":{"type":"string"},"client_id":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_port":{"type":"integer"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_expires_at":{"description":"When the OAuth token expires","type":"string"},"token_url":{"type":"string"},"token_valid":{"description":"Whether token is currently valid","type":"boolean"}},"type":"object"},"contracts.OAuthErrorDetails":{"description":"Structured discovery/failure details","properties":{"authorization_server_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"dcr_status":{"$ref":"#/components/schemas/contracts.DCRStatus"},"protected_resource_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"server_url":{"type":"string"}},"type":"object"},"contracts.OAuthFlowError":{"properties":{"correlation_id":{"description":"Flow tracking ID for log correlation","type":"string"},"debug_hint":{"description":"CLI command for log lookup","type":"string"},"details":{"$ref":"#/components/schemas/contracts.OAuthErrorDetails"},"error_code":{"description":"Machine-readable error code (e.g., OAUTH_NO_METADATA)","type":"string"},"error_type":{"description":"Category of OAuth runtime failure","type":"string"},"message":{"description":"Human-readable error description","type":"string"},"request_id":{"description":"HTTP request ID (from PR #237)","type":"string"},"server_name":{"description":"Server that failed OAuth","type":"string"},"success":{"description":"Always false","type":"boolean"},"suggestion":{"description":"Actionable remediation hint","type":"string"}},"type":"object"},"contracts.OAuthIssue":{"properties":{"documentation_url":{"type":"string"},"error":{"type":"string"},"issue":{"type":"string"},"missing_params":{"items":{"type":"string"},"type":"array","uniqueItems":false},"resolution":{"type":"string"},"server_name":{"type":"string"}},"type":"object"},"contracts.OAuthRequirement":{"properties":{"expires_at":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"state":{"type":"string"}},"type":"object"},"contracts.OAuthStartResponse":{"properties":{"auth_url":{"description":"Authorization URL (always included for manual use)","type":"string"},"browser_error":{"description":"Error message if browser launch failed","type":"string"},"browser_opened":{"description":"Whether browser launch succeeded","type":"boolean"},"correlation_id":{"description":"UUID for tracking this flow","type":"string"},"message":{"description":"Human-readable status message","type":"string"},"server_name":{"description":"Name of the server being authenticated","type":"string"},"success":{"description":"Always true for successful start","type":"boolean"}},"type":"object"},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"blocked_count":{"description":"Number of disabled (blocked) tools","type":"integer"},"changed_count":{"description":"Number of tools whose description/schema changed since approval","type":"integer"},"pending_count":{"description":"Number of newly discovered tools awaiting approval","type":"integer"}},"type":"object"},"contracts.RefreshRegistryResponse":{"properties":{"cleared":{"description":"number of cached entries dropped","type":"integer"},"registry_id":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"deep_scan":{"$ref":"#/components/schemas/contracts.DeepScanDescriptor"},"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary (baseline) scan pass — informational only.\nSpec 077 US3 (FR-008/FR-014): Status is derived SOLELY from the\ndeterministic baseline findings; a failed Docker deep scanner no longer\ndowngrades a clean verdict. That failure is surfaced via DeepScan instead.","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"init_timeout":{"description":"InitTimeout mirrors config.ServerConfig.InitTimeout (MCP-3322 / GH #760):\nthe per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override. Serialized as\na duration string (e.g. \"120s\"); nil/omitted means \"inherit the global\ndefault\". Surfaced on the GET path so clients can read back a configured\noverride; PATCH/POST accept it via AddServerRequest.","type":"string"},"isolation":{"$ref":"#/components/schemas/contracts.IsolationConfig"},"isolation_defaults":{"$ref":"#/components/schemas/contracts.IsolationDefaults"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"security_scan":{"$ref":"#/components/schemas/contracts.SecurityScanSummary"},"should_retry":{"type":"boolean"},"source_registry_id":{"description":"MCP-901 — registry provenance of an upstream that was added from a\nregistry. SourceRegistryID names the source registry (empty for\nmanually-configured servers); SourceRegistryProvenance is the trust tag\nrecorded at add time (\"official/trusted\" or \"custom/unverified\"). Both\nare projected from config.ServerConfig so the approval/quarantine view\ncan render an \"added from \u003cregistry\u003e · unverified\" origin badge. Optional\nand omitted when empty — clients that pre-date this treat them as absent.","type":"string"},"source_registry_provenance":{"type":"string"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"trust_mode":{"description":"TrustMode mirrors config.ServerConfig.TrustMode (spec 086): the per-server\ntrust tier (\"auto\"/\"scan\"/\"manual\"). Surfaced on the GET path so clients can\nread back the persisted mode; PATCH/POST accept it via AddServerRequest.\nOmitted when empty (server predates the field / relies on legacy flags).","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"user_logged_out":{"description":"True if user explicitly logged out (prevents auto-reconnection)","type":"boolean"},"working_dir":{"type":"string"}},"type":"object"},"contracts.ServerActionResponse":{"properties":{"action":{"type":"string"},"async":{"type":"boolean"},"server":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ServerStats":{"properties":{"connected_servers":{"type":"integer"},"docker_containers":{"type":"integer"},"quarantined_servers":{"type":"integer"},"token_metrics":{"$ref":"#/components/schemas/contracts.ServerTokenMetrics"},"total_servers":{"type":"integer"},"total_tools":{"type":"integer"}},"type":"object"},"contracts.ServerTokenMetrics":{"properties":{"average_query_result_size":{"description":"Typical retrieve_tools output (tokens)","type":"integer"},"per_server_tool_list_sizes":{"additionalProperties":{"type":"integer"},"description":"Token size per server","type":"object"},"saved_tokens":{"description":"Difference","type":"integer"},"saved_tokens_percentage":{"description":"Percentage saved","type":"number"},"total_server_tool_list_size":{"description":"All upstream tools combined (tokens)","type":"integer"}},"type":"object"},"contracts.SuccessResponse":{"properties":{"data":{"type":"object"},"success":{"type":"boolean"}},"type":"object"},"contracts.TokenMetrics":{"description":"Token usage metrics (nil for older records)","properties":{"encoding":{"description":"Encoding used (e.g., cl100k_base)","type":"string"},"estimated_cost":{"description":"Optional cost estimate","type":"number"},"input_tokens":{"description":"Tokens in the request","type":"integer"},"model":{"description":"Model used for tokenization","type":"string"},"output_tokens":{"description":"Tokens in the response","type":"integer"},"total_tokens":{"description":"Total tokens (input + output)","type":"integer"},"truncated_tokens":{"description":"Tokens removed by truncation","type":"integer"},"was_truncated":{"description":"Whether response was truncated","type":"boolean"}},"type":"object"},"contracts.Tool":{"properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"approval_status":{"type":"string"},"config_denied":{"description":"ConfigDenied is true when the tool is denied by the server's static\nenabled_tools / disabled_tools config. The user cannot override this toggle.","type":"boolean"},"description":{"type":"string"},"disabled":{"description":"Disabled mirrors ToolApprovalRecord.Disabled so per-tool enable state is\navailable without a second round-trip to the approvals endpoint. Absent\nin the JSON when false (default) to keep responses compact.","type":"boolean"},"held_reason":{"description":"HeldReason, HeldVerdict and HeldSignals mirror the same-named fields on\nstorage.ToolApprovalRecord: the offline-scan evidence that made\ntrust_mode: scan hold this tool for review (spec 086 FR-018). HeldSignals\nnames the matched deterministic check ids, e.g.\n\"tpa.TPA-2026-0001.hidden_instruction\", so a reviewer can see WHY the tool\nis held. All three are omitted for tools that are not held by the scan gate\n(including every record written before the field existed).","type":"string"},"held_signals":{"items":{"type":"string"},"type":"array","uniqueItems":false},"held_verdict":{"type":"string"},"last_used":{"type":"string"},"name":{"type":"string"},"schema":{"type":"object"},"server_name":{"type":"string"},"usage":{"type":"integer"}},"type":"object"},"contracts.ToolAnnotation":{"description":"Tool behavior hints snapshot","properties":{"destructiveHint":{"type":"boolean"},"idempotentHint":{"type":"boolean"},"openWorldHint":{"type":"boolean"},"readOnlyHint":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"contracts.ToolCallRecord":{"description":"The new tool call record","properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"arguments":{"description":"Tool arguments","type":"object"},"config_path":{"description":"Active config file path","type":"string"},"duration":{"description":"Duration in nanoseconds","type":"integer"},"error":{"description":"Error message (failure only)","type":"string"},"execution_type":{"description":"\"direct\" or \"code_execution\"","type":"string"},"id":{"description":"Unique identifier","type":"string"},"mcp_client_name":{"description":"MCP client name from InitializeRequest","type":"string"},"mcp_client_version":{"description":"MCP client version","type":"string"},"mcp_session_id":{"description":"MCP session identifier","type":"string"},"metrics":{"$ref":"#/components/schemas/contracts.TokenMetrics"},"parent_call_id":{"description":"Links nested calls to parent code_execution","type":"string"},"request_id":{"description":"Request correlation ID","type":"string"},"response":{"description":"Tool response (success only)","type":"object"},"server_id":{"description":"Server identity hash","type":"string"},"server_name":{"description":"Human-readable server name","type":"string"},"timestamp":{"description":"When the call was made","type":"string"},"tool_name":{"description":"Tool name (without server prefix)","type":"string"}},"type":"object"},"contracts.UpdateInfo":{"description":"Update information (if available)","properties":{"available":{"description":"Whether an update is available","type":"boolean"},"check_error":{"description":"Error message if update check failed","type":"string"},"checked_at":{"description":"When the update check was performed","type":"string"},"install_channel":{"description":"Detected install channel (homebrew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown) — Spec 079 FR-008","type":"string"},"is_prerelease":{"description":"Whether the latest version is a prerelease","type":"boolean"},"latest_version":{"description":"Latest version available (e.g., \"v1.2.3\")","type":"string"},"nudges_suppressed":{"description":"UI surfaces must stay quiet (CI / non-interactive context); machine-readable fields still report the facts — Spec 079 FR-019","type":"boolean"},"release_url":{"description":"URL to the release page","type":"string"},"update_command":{"description":"One-line update command for the channel; only set when an update is available and the channel has one — Spec 079 FR-009","type":"string"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.UsageAggregateResponse":{"properties":{"freshness_ms":{"description":"age of the underlying snapshot in ms","type":"integer"},"generated_at":{"type":"string"},"other":{"$ref":"#/components/schemas/contracts.UsageOtherBucket"},"timeline":{"items":{"$ref":"#/components/schemas/contracts.UsageTimeBucket"},"type":"array","uniqueItems":false},"token_source":{"description":"\"bytes\" (size-based proxy, FR-006)","type":"string"},"tokens_saved":{"description":"echoed from ServerTokenMetrics (FR-007)","type":"integer"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"window":{"type":"string"}},"type":"object"},"contracts.UsageOtherBucket":{"description":"present only when the list was truncated to top-N","properties":{"calls":{"type":"integer"},"tools_folded":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageTimeBucket":{"properties":{"calls":{"type":"integer"},"errors":{"type":"integer"},"start":{"type":"string"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageToolStat":{"properties":{"avg_req_bytes":{"description":"null when no sized request calls","type":"integer"},"avg_resp_bytes":{"description":"null when sized_calls == 0 (only legacy 0-byte calls)","type":"integer"},"blocked":{"type":"integer"},"calls":{"type":"integer"},"error_rate":{"type":"number"},"errors":{"type":"integer"},"last_used":{"type":"string"},"p50_ms":{"type":"integer"},"p95_ms":{"type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"init_timeout":{"description":"InitTimeout is the per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override\n(MCP-3322 / GH #760), serialized as a duration string (e.g. \"120s\"). A nil\npointer means \"leave unchanged\" on PATCH; a present value is applied.\nMirrors config.ServerConfig.InitTimeout's *Duration tri-state.","type":"string"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"reconnect_on_use":{"type":"boolean"},"trust_mode":{"description":"TrustMode is the per-server trust tier (spec 086): \"auto\", \"scan\", or\n\"manual\". Empty means \"leave unchanged\" on PATCH (and inherit the migrated\ndefault on create). A non-empty value is applied to ServerConfig.TrustMode\nand resolved by EffectiveTrustMode (an unrecognized value fails closed to\nmanual). This is the REST seam for changing the trust tier via\nPOST/PATCH /api/v1/servers.","type":"string"},"url":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.CanonicalConfigPath":{"properties":{"description":{"description":"Brief description","type":"string"},"exists":{"description":"Whether the file exists","type":"boolean"},"format":{"description":"Format identifier (e.g., \"claude_desktop\")","type":"string"},"name":{"description":"Display name (e.g., \"Claude Desktop\")","type":"string"},"os":{"description":"Operating system (darwin, windows, linux)","type":"string"},"path":{"description":"Full path to the config file","type":"string"}},"type":"object"},"httpapi.CanonicalConfigPathsResponse":{"properties":{"os":{"description":"Current operating system","type":"string"},"paths":{"description":"List of canonical config paths","items":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPath"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.ImportFromPathRequest":{"properties":{"format":{"description":"Optional format hint","type":"string"},"path":{"description":"File path to import from","type":"string"},"rename":{"additionalProperties":{"type":"string"},"description":"Rename maps a server name → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys are matched against\neither the raw source name (OriginalName) or the sanitized name shown\nin the preview (Server.Name); these differ for names that need\nsanitizing (e.g. \"Figma Desktop\" → \"Figma_Desktop\"). Keys not present\nin the imported set are ignored.","type":"object"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportRequest":{"properties":{"content":{"description":"Raw JSON or TOML content","type":"string"},"format":{"description":"Optional format hint","type":"string"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportResponse":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/configimport.FailedServer"},"type":"array","uniqueItems":false},"format":{"type":"string"},"format_name":{"type":"string"},"imported":{"items":{"$ref":"#/components/schemas/httpapi.ImportedServerResponse"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/configimport.SkippedServer"},"type":"array","uniqueItems":false},"summary":{"$ref":"#/components/schemas/configimport.ImportSummary"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportedServerResponse":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (image,\nnetwork_mode, extra_args, working_dir, enabled). A nil pointer\nmeans \"do not touch isolation config\"; an empty-but-present\nobject on PATCH intentionally clears the overrides.","properties":{"enabled":{"type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.OnboardingMarkRequest":{"properties":{"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value. The stored enum is wider (Spec 080\nFR-001): a \"skipped\" request for a previously untouched connect step\nis upgraded server-side to \"completed_external\" when the install\nshows positive evidence of an external connection (Spec 080 FR-002).\n\"completed_external\" is NOT accepted from clients — it must never be\npersisted without that server-verified evidence (edge case: \"never\nguess completed_external without positive evidence\").","type":"string"},"engaged":{"description":"Engaged marks the wizard as engaged (completed or explicitly skipped).\nOnce true, the wizard does not auto-show again.","type":"boolean"},"mark_shown":{"description":"MarkShown records the wizard's first display time if not already set.","type":"boolean"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","type":"string"}},"type":"object"},"httpapi.SetActiveProfileRequest":{"properties":{"active_profile":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.UndoConnectRequest":{"properties":{"backup_name":{"description":"BackupName is the bare filename (filepath.Base) of the backup returned as\nbackup_path by the preceding connect — a name, never a path. Undo resolves\nthe full path server-side by joining it with the client's own config\ndirectory, so a client-supplied value can never contribute a directory\ncomponent (traversal is impossible by construction). Empty means the\nconnect created the file (no prior file existed), so undo removes it.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned upstream servers (MCP-2769). It is OFF by\ndefault and deliberately kept out of the AllowedSystemVars default list:\nproxy URLs frequently carry credentials (http://user:pass@proxy), so\nforwarding them to every stdio upstream is a credential-leak risk. When\nenabled, values are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"},"telemetry.FeedbackContext":{"properties":{"arch":{"type":"string"},"connected_server_count":{"type":"integer"},"edition":{"type":"string"},"os":{"type":"string"},"routing_mode":{"type":"string"},"server_count":{"type":"integer"},"version":{"type":"string"}},"type":"object"},"telemetry.FeedbackRequest":{"properties":{"category":{"description":"bug, feature, other","type":"string"},"context":{"$ref":"#/components/schemas/telemetry.FeedbackContext"},"email":{"type":"string"},"message":{"type":"string"}},"type":"object"},"telemetry.FeedbackResponse":{"properties":{"error":{"type":"string"},"issue_url":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"description":"API key authentication via query parameter. Use ?apikey=your-key","in":"query","name":"apikey","type":"apiKey"}}}, "info": {"contact":{"name":"MCPProxy Support","url":"https://github.com/smart-mcp-proxy/mcpproxy-go"},"description":"{{escape .Description}}","license":{"name":"MIT","url":"https://opensource.org/licenses/MIT"},"title":"{{.Title}}","version":"{{.Version}}"}, "externalDocs": {"description":"","url":""}, - "paths": {"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to export (1-50000, default 10000)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get usage statistics aggregate","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/config":{"get":{"description":"Retrieves the current MCPProxy configuration including all server definitions, global settings, and runtime parameters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetConfigResponse"}}},"description":"Configuration retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Apply configuration","tags":["config"]}},"/api/v1/config/docker-isolation":{"patch":{"description":"Convenience endpoint to flip ` + "`" + `docker_isolation.enabled` + "`" + ` without resending the full config. Persists to disk via the existing config writer — the file watcher then hot-reloads the change. Returns the new state and whether a restart is required for existing connections to pick it up.","requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"}},"type":"object"}}},"description":"New isolation state","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Isolation toggle applied"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Toggle global Docker isolation","tags":["config"]}},"/api/v1/config/validate":{"post":{"description":"Validates a provided MCPProxy configuration without applying it. Checks for syntax errors, invalid server definitions, conflicting settings, and other configuration issues.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to validate","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ValidateConfigResponse"}}},"description":"Configuration validation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Validation failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Validate configuration","tags":["config"]}},"/api/v1/connect":{"get":{"description":"Returns the connection status for all known MCP client applications.\nEach entry indicates whether the client config file exists and whether\nMCPProxy is currently registered in it.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"List of ClientStatus objects"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List client connection status","tags":["connect"]}},"/api/v1/connect/{client}":{"delete":{"description":"Remove the MCPProxy entry from the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional parameters (server_name)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or entry not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disconnect MCPProxy from a client","tags":["connect"]},"get":{"description":"Resolves one client's status by reading its config file on demand.\nThis is the only Connect endpoint that opens a client config file, so\non macOS it is the sole place an App-Data privacy prompt may legitimately\nappear (scoped to this user action). Resolves access_state to\naccessible|absent|denied|malformed and populates remediation when denied.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get a single client's connection status (on-demand)","tags":["connect"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Already connected (use force=true)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Connect MCPProxy to a client","tags":["connect"]}},"/api/v1/connect/{client}/preview":{"get":{"description":"Returns the exact entry a subsequent connect would add to the client's\nconfig — target path, server key, entry name, and entry contents — WITHOUT\nmodifying the file or creating a backup (Spec 078 US1). The embedded API key\nis masked in the payload; contains_api_key flags that a credential is written.\nentry_exists distinguishes a create from an overwrite of a same-named entry.\nReads the config on demand to classify create-vs-overwrite, so on macOS this\nmay raise an App-Data privacy prompt; a denial returns 403 + remediation.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Entry name to preview (defaults to mcpproxy); mirror the value passed to POST connect","in":"query","name":"server_name","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectPreview"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview the change a connect would make (no write)","tags":["connect"]}},"/api/v1/connect/{client}/undo":{"post":{"description":"Reverts the connect that produced the named backup (Spec 078 US3):\nrestores the client config byte-for-byte from that backup, or — when\nbackup_name is empty because the connect created the file — deletes the\ncreated file. backup_name is the bare filename of the backup the connect\nreturned (never a path); undo resolves the full path server-side inside\nthe client's own config directory, so a client value cannot escape it.\nRefuses with 409 when the config changed since the connect (undo never\nclobbers later edits; use DELETE /connect/{client} for a surgical entry\nremoval instead). Takes its own safety backup first; its path is returned\nas backup_path in the result.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UndoConnectRequest"}}},"description":"Undo parameters (server_name, backup_name = the bare filename of the backup the preceding connect returned)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult (action restored|deleted)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (e.g. backup_name is a path, or not a backup of this client's config)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or backup no longer exists"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Config changed since connect; undo refused"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Undo a connect, restoring the pre-connect config","tags":["connect"]}},"/api/v1/diagnostics":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/docker/status":{"get":{"description":"Retrieve current Docker availability and recovery status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Docker status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get Docker status","tags":["docker"]}},"/api/v1/doctor":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/feedback":{"post":{"description":"Submit a bug report, feature request, or general feedback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackRequest"}}},"description":"Feedback request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Bad Request"},"429":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyAuth":[]}],"summary":"Submit feedback","tags":["feedback"]}},"/api/v1/index/search":{"get":{"description":"Search across all upstream MCP server tools using BM25 keyword search","parameters":[{"description":"Search query","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Maximum number of results","in":"query","name":"limit","schema":{"default":10,"maximum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchToolsResponse"}}},"description":"Search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing query parameter)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search for tools","tags":["tools"]}},"/api/v1/info":{"get":{"description":"Get essential server metadata including version, web UI URL, endpoint addresses, and update availability\nThis endpoint is designed for tray-core communication and version checking\nUse refresh=true query parameter to force an immediate update check against GitHub","parameters":[{"description":"Force immediate update check against GitHub","in":"query","name":"refresh","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Server information with optional update info"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server information","tags":["status"]}},"/api/v1/onboarding/mark":{"post":{"description":"Updates wizard engagement and per-step status. Once engaged is\ntrue, the wizard does not auto-show again, even if state regresses.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.OnboardingMarkRequest"}}},"description":"Mark request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Mark onboarding wizard state (Spec 046)","tags":["onboarding"]}},"/api/v1/onboarding/state":{"get":{"description":"Returns the wizard engagement record alongside live predicates\n(whether any client is connected, whether any server is configured),\nplus a derived ShouldShowWizard flag the frontend can rely on.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"OnboardingStateResponse"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get onboarding wizard state and predicates (Spec 046)","tags":["onboarding"]}},"/api/v1/profiles":{"get":{"description":"List all configured profiles with their effective servers and indexed tool count (Profiles v2). A profile scopes tool discovery and calls to a named subset of upstream servers.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Profile list"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Configuration unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List configured profiles","tags":["profiles"]}},"/api/v1/profiles/active":{"get":{"description":"Get the server-level default active profile used by UI surfaces (Web UI / tray). Empty string means \"all servers\". Note: within a live MCP session, the set_profile tool selection takes precedence over this default.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the default active profile","tags":["profiles"]},"put":{"description":"Set the server-level default active profile for UI surfaces. The slug must match a configured profile; pass an empty string to clear. This does not affect live MCP sessions, which use the set_profile tool.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.SetActiveProfileRequest"}}},"description":"Profile slug to activate (empty clears)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request body"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Set the default active profile","tags":["profiles"]}},"/api/v1/registries":{"get":{"description":"Retrieves list of all MCP server registries that can be browsed for discovering and installing new upstream servers. Includes registry metadata, server counts, and API endpoints.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetRegistriesResponse"}}},"description":"Registries retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to list registries"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List available MCP server registries","tags":["registries"]},"post":{"description":"Adds a generic modelcontextprotocol/registry v0.1 https endpoint as a custom registry (MCP-866). The source is always tagged custom/unverified, so every server discovered through it lands quarantined and can never skip quarantine.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddRegistrySourceRequest"}}},"description":"Registry source (https url + optional protocol/id/name)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source added"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/contracts.ErrorResponse"},{"$ref":"#/components/schemas/contracts.ErrorResponse"}]}}},"description":"Forbidden (agent tokens cannot mutate registries)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin | duplicate_registry"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a user-supplied registry source","tags":["registries"]}},"/api/v1/registries/{id}":{"delete":{"description":"Removes a custom/unverified registry previously added via add-source (MCP-1057). Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source removed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove a user-added custom registry source","tags":["registries"]},"put":{"description":"Updates a custom registry previously added via add-source (MCP-1072): name, url, servers-url. Empty fields are left unchanged. Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found; a non-https url yields invalid_registry_url. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.EditRegistrySourceRequest"}}},"description":"Fields to update (name/url/servers_url; empty = unchanged)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required | invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Edit a user-added custom registry source","tags":["registries"]}},"/api/v1/registries/{id}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot add servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove an upstream server","tags":["servers"]},"patch":{"description":"Update specific fields of an existing upstream MCP server configuration.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Fields to update (all optional)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server updated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - no fields or invalid body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/config-to-secret":{"post":{"description":"Atomically reads the real value from the server config, stores it in the OS keyring, and rewrites the config field to ` + "`" + `${keyring:\u003cname\u003e}` + "`" + `. Unblocks the UI's Convert-to-secret affordance for values the API redacts on the read path.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored, config updated with reference"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad scope/key/secret_name, or value is already a reference / empty"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server or key not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver or config update failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Convert a header / env value to a keyring secret","tags":["servers"]}},"/api/v1/servers/{id}/disable":{"post":{"description":"Disable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server disabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/discover-tools":{"post":{"description":"Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool discovery triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot discover tools)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to discover tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Discover tools for a specific server","tags":["servers"]}},"/api/v1/servers/{id}/enable":{"post":{"description":"Enable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server enabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/login":{"post":{"description":"Initiate OAuth authentication flow for a specific upstream MCP server. Returns structured OAuth start response with correlation ID for tracking.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthStartResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthFlowError"}}},"description":"OAuth error (client_id required, DCR failed, etc.)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Trigger OAuth login for server","tags":["servers"]}},"/api/v1/servers/{id}/logout":{"post":{"description":"Clear OAuth authentication token and disconnect a specific upstream MCP server. The server will need to re-authenticate before tools can be used again.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"OAuth logout completed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled or read-only mode)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Clear OAuth token and disconnect server","tags":["servers"]}},"/api/v1/servers/{id}/logs":{"get":{"description":"Retrieve log entries for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Number of log lines to retrieve","in":"query","name":"tail","schema":{"default":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerLogsResponse"}}},"description":"Server logs retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server logs","tags":["servers"]}},"/api/v1/servers/{id}/quarantine":{"post":{"description":"Place a specific upstream MCP server in quarantine to prevent tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server quarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Quarantine a server","tags":["servers"]}},"/api/v1/servers/{id}/refresh":{"post":{"description":"Re-discover and re-index a specific upstream MCP server's tools without changing any security state. Alias of discover-tools, named for the upstream_servers 'refresh' operation; use it to make just-approved tools searchable immediately.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool refresh triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot refresh)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Refresh a server's tools","tags":["servers"]}},"/api/v1/servers/{id}/restart":{"post":{"description":"Restart the connection to a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server restarted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/tool-calls":{"get":{"description":"Retrieves tool call history filtered by upstream server ID. Returns recent tool executions for the specified server including timestamps, arguments, results, and errors. Useful for server-specific debugging and monitoring.","parameters":[{"description":"Upstream server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolCallsResponse"}}},"description":"Server tool calls retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get server tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history for specific server","tags":["tool-calls"]}},"/api/v1/servers/{id}/tools":{"get":{"description":"Retrieve all available tools for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolsResponse"}}},"description":"Server tools retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/block":{"post":{"description":"Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The \"blocked\" field counts tools actually blocked.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Block result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Block (approve+disable) tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/disable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/enable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/unquarantine":{"post":{"description":"Remove a specific upstream MCP server from quarantine to allow tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server unquarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Unquarantine a server","tags":["servers"]}},"/api/v1/sessions":{"get":{"description":"Retrieves paginated list of active and recent MCP client sessions. Each session represents a connection from an MCP client to MCPProxy, tracking initialization time, tool calls, and connection status.","parameters":[{"description":"Maximum number of sessions to return (1-100, default 10)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of sessions to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get sessions"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get active MCP sessions","tags":["sessions"]}},"/api/v1/sessions/{id}":{"get":{"description":"Retrieves detailed information about a specific MCP client session including initialization parameters, connection status, tool call count, and activity timestamps.","parameters":[{"description":"Session ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionDetailResponse"}}},"description":"Session details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get MCP session details by ID","tags":["sessions"]}},"/api/v1/stats/tokens":{"get":{"description":"Retrieve token savings statistics across all servers and sessions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Token statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get token savings statistics","tags":["stats"]}},"/api/v1/status":{"get":{"description":"Get comprehensive server status including running state, listen address, upstream statistics, and timestamp","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server status","tags":["status"]}},"/api/v1/telemetry/payload":{"get":{"description":"Render the exact JSON heartbeat payload that mcpproxy would next send to the telemetry endpoint, without making a network call. Counters in the payload reflect the current in-memory state. Spec 042.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Telemetry heartbeat payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}}, + "paths": {"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Omit arguments, response and metadata (default: false). For clients that render summary fields only; has_sensitive_data is still derived before metadata is dropped.","in":"query","name":"exclude_payloads","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to export (1-50000, default 10000)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get usage statistics aggregate","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/config":{"get":{"description":"Retrieves the current MCPProxy configuration including all server definitions, global settings, and runtime parameters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetConfigResponse"}}},"description":"Configuration retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Apply configuration","tags":["config"]}},"/api/v1/config/docker-isolation":{"patch":{"description":"Convenience endpoint to flip ` + "`" + `docker_isolation.enabled` + "`" + ` without resending the full config. Persists to disk via the existing config writer — the file watcher then hot-reloads the change. Returns the new state and whether a restart is required for existing connections to pick it up.","requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"}},"type":"object"}}},"description":"New isolation state","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Isolation toggle applied"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Toggle global Docker isolation","tags":["config"]}},"/api/v1/config/validate":{"post":{"description":"Validates a provided MCPProxy configuration without applying it. Checks for syntax errors, invalid server definitions, conflicting settings, and other configuration issues.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to validate","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ValidateConfigResponse"}}},"description":"Configuration validation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Validation failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Validate configuration","tags":["config"]}},"/api/v1/connect":{"get":{"description":"Returns the connection status for all known MCP client applications.\nEach entry indicates whether the client config file exists and whether\nMCPProxy is currently registered in it.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"List of ClientStatus objects"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List client connection status","tags":["connect"]}},"/api/v1/connect/{client}":{"delete":{"description":"Remove the MCPProxy entry from the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional parameters (server_name)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or entry not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disconnect MCPProxy from a client","tags":["connect"]},"get":{"description":"Resolves one client's status by reading its config file on demand.\nThis is the only Connect endpoint that opens a client config file, so\non macOS it is the sole place an App-Data privacy prompt may legitimately\nappear (scoped to this user action). Resolves access_state to\naccessible|absent|denied|malformed and populates remediation when denied.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get a single client's connection status (on-demand)","tags":["connect"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Already connected (use force=true)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Connect MCPProxy to a client","tags":["connect"]}},"/api/v1/connect/{client}/preview":{"get":{"description":"Returns the exact entry a subsequent connect would add to the client's\nconfig — target path, server key, entry name, and entry contents — WITHOUT\nmodifying the file or creating a backup (Spec 078 US1). The embedded API key\nis masked in the payload; contains_api_key flags that a credential is written.\nentry_exists distinguishes a create from an overwrite of a same-named entry.\nReads the config on demand to classify create-vs-overwrite, so on macOS this\nmay raise an App-Data privacy prompt; a denial returns 403 + remediation.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Entry name to preview (defaults to mcpproxy); mirror the value passed to POST connect","in":"query","name":"server_name","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectPreview"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview the change a connect would make (no write)","tags":["connect"]}},"/api/v1/connect/{client}/undo":{"post":{"description":"Reverts the connect that produced the named backup (Spec 078 US3):\nrestores the client config byte-for-byte from that backup, or — when\nbackup_name is empty because the connect created the file — deletes the\ncreated file. backup_name is the bare filename of the backup the connect\nreturned (never a path); undo resolves the full path server-side inside\nthe client's own config directory, so a client value cannot escape it.\nRefuses with 409 when the config changed since the connect (undo never\nclobbers later edits; use DELETE /connect/{client} for a surgical entry\nremoval instead). Takes its own safety backup first; its path is returned\nas backup_path in the result.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UndoConnectRequest"}}},"description":"Undo parameters (server_name, backup_name = the bare filename of the backup the preceding connect returned)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult (action restored|deleted)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (e.g. backup_name is a path, or not a backup of this client's config)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or backup no longer exists"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Config changed since connect; undo refused"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Undo a connect, restoring the pre-connect config","tags":["connect"]}},"/api/v1/diagnostics":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/docker/status":{"get":{"description":"Retrieve current Docker availability and recovery status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Docker status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get Docker status","tags":["docker"]}},"/api/v1/doctor":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/feedback":{"post":{"description":"Submit a bug report, feature request, or general feedback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackRequest"}}},"description":"Feedback request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Bad Request"},"429":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyAuth":[]}],"summary":"Submit feedback","tags":["feedback"]}},"/api/v1/index/search":{"get":{"description":"Search across all upstream MCP server tools using BM25 keyword search","parameters":[{"description":"Search query","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Maximum number of results","in":"query","name":"limit","schema":{"default":10,"maximum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchToolsResponse"}}},"description":"Search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing query parameter)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search for tools","tags":["tools"]}},"/api/v1/info":{"get":{"description":"Get essential server metadata including version, web UI URL, endpoint addresses, and update availability\nThis endpoint is designed for tray-core communication and version checking\nUse refresh=true query parameter to force an immediate update check against GitHub","parameters":[{"description":"Force immediate update check against GitHub","in":"query","name":"refresh","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Server information with optional update info"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server information","tags":["status"]}},"/api/v1/onboarding/mark":{"post":{"description":"Updates wizard engagement and per-step status. Once engaged is\ntrue, the wizard does not auto-show again, even if state regresses.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.OnboardingMarkRequest"}}},"description":"Mark request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Mark onboarding wizard state (Spec 046)","tags":["onboarding"]}},"/api/v1/onboarding/state":{"get":{"description":"Returns the wizard engagement record alongside live predicates\n(whether any client is connected, whether any server is configured),\nplus a derived ShouldShowWizard flag the frontend can rely on.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"OnboardingStateResponse"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get onboarding wizard state and predicates (Spec 046)","tags":["onboarding"]}},"/api/v1/profiles":{"get":{"description":"List all configured profiles with their effective servers and indexed tool count (Profiles v2). A profile scopes tool discovery and calls to a named subset of upstream servers.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Profile list"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Configuration unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List configured profiles","tags":["profiles"]}},"/api/v1/profiles/active":{"get":{"description":"Get the server-level default active profile used by UI surfaces (Web UI / tray). Empty string means \"all servers\". Note: within a live MCP session, the set_profile tool selection takes precedence over this default.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the default active profile","tags":["profiles"]},"put":{"description":"Set the server-level default active profile for UI surfaces. The slug must match a configured profile; pass an empty string to clear. This does not affect live MCP sessions, which use the set_profile tool.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.SetActiveProfileRequest"}}},"description":"Profile slug to activate (empty clears)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request body"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Set the default active profile","tags":["profiles"]}},"/api/v1/registries":{"get":{"description":"Retrieves list of all MCP server registries that can be browsed for discovering and installing new upstream servers. Includes registry metadata, server counts, and API endpoints.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetRegistriesResponse"}}},"description":"Registries retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to list registries"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List available MCP server registries","tags":["registries"]},"post":{"description":"Adds a generic modelcontextprotocol/registry v0.1 https endpoint as a custom registry (MCP-866). The source is always tagged custom/unverified, so every server discovered through it lands quarantined and can never skip quarantine.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddRegistrySourceRequest"}}},"description":"Registry source (https url + optional protocol/id/name)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source added"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/contracts.ErrorResponse"},{"$ref":"#/components/schemas/contracts.ErrorResponse"}]}}},"description":"Forbidden (agent tokens cannot mutate registries)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin | duplicate_registry"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a user-supplied registry source","tags":["registries"]}},"/api/v1/registries/{id}":{"delete":{"description":"Removes a custom/unverified registry previously added via add-source (MCP-1057). Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source removed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove a user-added custom registry source","tags":["registries"]},"put":{"description":"Updates a custom registry previously added via add-source (MCP-1072): name, url, servers-url. Empty fields are left unchanged. Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found; a non-https url yields invalid_registry_url. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.EditRegistrySourceRequest"}}},"description":"Fields to update (name/url/servers_url; empty = unchanged)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required | invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Edit a user-added custom registry source","tags":["registries"]}},"/api/v1/registries/{id}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot add servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove an upstream server","tags":["servers"]},"patch":{"description":"Update specific fields of an existing upstream MCP server configuration.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Fields to update (all optional)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server updated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - no fields or invalid body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/config-to-secret":{"post":{"description":"Atomically reads the real value from the server config, stores it in the OS keyring, and rewrites the config field to ` + "`" + `${keyring:\u003cname\u003e}` + "`" + `. Unblocks the UI's Convert-to-secret affordance for values the API redacts on the read path.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored, config updated with reference"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad scope/key/secret_name, or value is already a reference / empty"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server or key not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver or config update failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Convert a header / env value to a keyring secret","tags":["servers"]}},"/api/v1/servers/{id}/disable":{"post":{"description":"Disable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server disabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/discover-tools":{"post":{"description":"Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool discovery triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot discover tools)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to discover tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Discover tools for a specific server","tags":["servers"]}},"/api/v1/servers/{id}/enable":{"post":{"description":"Enable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server enabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/login":{"post":{"description":"Initiate OAuth authentication flow for a specific upstream MCP server. Returns structured OAuth start response with correlation ID for tracking.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthStartResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthFlowError"}}},"description":"OAuth error (client_id required, DCR failed, etc.)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Trigger OAuth login for server","tags":["servers"]}},"/api/v1/servers/{id}/logout":{"post":{"description":"Clear OAuth authentication token and disconnect a specific upstream MCP server. The server will need to re-authenticate before tools can be used again.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"OAuth logout completed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled or read-only mode)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Clear OAuth token and disconnect server","tags":["servers"]}},"/api/v1/servers/{id}/logs":{"get":{"description":"Retrieve log entries for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Number of log lines to retrieve","in":"query","name":"tail","schema":{"default":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerLogsResponse"}}},"description":"Server logs retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server logs","tags":["servers"]}},"/api/v1/servers/{id}/quarantine":{"post":{"description":"Place a specific upstream MCP server in quarantine to prevent tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server quarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Quarantine a server","tags":["servers"]}},"/api/v1/servers/{id}/refresh":{"post":{"description":"Re-discover and re-index a specific upstream MCP server's tools without changing any security state. Alias of discover-tools, named for the upstream_servers 'refresh' operation; use it to make just-approved tools searchable immediately.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool refresh triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot refresh)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Refresh a server's tools","tags":["servers"]}},"/api/v1/servers/{id}/restart":{"post":{"description":"Restart the connection to a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server restarted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/tool-calls":{"get":{"description":"Retrieves tool call history filtered by upstream server ID. Returns recent tool executions for the specified server including timestamps, arguments, results, and errors. Useful for server-specific debugging and monitoring.","parameters":[{"description":"Upstream server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolCallsResponse"}}},"description":"Server tool calls retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get server tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history for specific server","tags":["tool-calls"]}},"/api/v1/servers/{id}/tools":{"get":{"description":"Retrieve all available tools for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolsResponse"}}},"description":"Server tools retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/block":{"post":{"description":"Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The \"blocked\" field counts tools actually blocked.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Block result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Block (approve+disable) tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/disable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/enable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/unquarantine":{"post":{"description":"Remove a specific upstream MCP server from quarantine to allow tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server unquarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Unquarantine a server","tags":["servers"]}},"/api/v1/sessions":{"get":{"description":"Retrieves paginated list of active and recent MCP client sessions. Each session represents a connection from an MCP client to MCPProxy, tracking initialization time, tool calls, and connection status.","parameters":[{"description":"Maximum number of sessions to return (1-100, default 10)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of sessions to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter by session status","in":"query","name":"status","schema":{"enum":["active","closed"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid status filter"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get sessions"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get active MCP sessions","tags":["sessions"]}},"/api/v1/sessions/{id}":{"get":{"description":"Retrieves detailed information about a specific MCP client session including initialization parameters, connection status, tool call count, and activity timestamps.","parameters":[{"description":"Session ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionDetailResponse"}}},"description":"Session details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get MCP session details by ID","tags":["sessions"]}},"/api/v1/stats/tokens":{"get":{"description":"Retrieve token savings statistics across all servers and sessions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Token statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get token savings statistics","tags":["stats"]}},"/api/v1/status":{"get":{"description":"Get comprehensive server status including running state, listen address, upstream statistics, and timestamp","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server status","tags":["status"]}},"/api/v1/telemetry/payload":{"get":{"description":"Render the exact JSON heartbeat payload that mcpproxy would next send to the telemetry endpoint, without making a network call. Counters in the payload reflect the current in-memory state. Spec 042.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Telemetry heartbeat payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}}, "openapi": "3.1.0" }` diff --git a/oas/swagger.yaml b/oas/swagger.yaml index f9b207d1b..46f4534d5 100644 --- a/oas/swagger.yaml +++ b/oas/swagger.yaml @@ -3108,6 +3108,13 @@ paths: name: offset schema: type: integer + - description: 'Omit arguments, response and metadata (default: false). For + clients that render summary fields only; has_sensitive_data is still derived + before metadata is dropped.' + in: query + name: exclude_payloads + schema: + type: boolean requestBody: content: application/json: @@ -6027,6 +6034,14 @@ paths: name: offset schema: type: integer + - description: Filter by session status + in: query + name: status + schema: + enum: + - active + - closed + type: string responses: "200": content: @@ -6034,6 +6049,12 @@ paths: schema: $ref: '#/components/schemas/contracts.GetSessionsResponse' description: Sessions retrieved successfully + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/contracts.ErrorResponse' + description: Invalid status filter "401": content: application/json: